mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge data-model additions into release/v1.19.2
Add per-bucket HR min/max (migration 0188) threaded through batch ingest and the series reader; make telegram_chat_id unique (0189) with a defensive pre-clean; make migration 0187 rerun-safe. Capture a numeric measurement from a Telegram reminder reply (source=TELEGRAM, strict chat binding, self-cleaning); blood pressure stays satisfy-only pending a guided two-number prompt.
This commit is contained in:
commit
ad2634e41b
21 files changed
+855
-17
No files matched your search
@@ -85,7 +85,11 @@ function makeRequest(body: unknown): NextRequest {
|
||||
});
|
||||
}
|
||||
|
||||
function hrBucketEntry(externalId: string, value: number) {
|
||||
function hrBucketEntry(
|
||||
externalId: string,
|
||||
value: number,
|
||||
spread?: { valueMin: number; valueMax: number },
|
||||
) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value,
|
||||
@@ -93,6 +97,7 @@ function hrBucketEntry(externalId: string, value: number) {
|
||||
startDate: "2026-06-21T14:00:00.000Z",
|
||||
endDate: "2026-06-21T14:59:59.000Z",
|
||||
externalId,
|
||||
...(spread ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +151,69 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].value).toBe(72);
|
||||
});
|
||||
|
||||
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
hrBucketEntry(HR_BUCKET_ID, 72, { valueMin: 58, valueMax: 96 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: { value: number; valueMin: number; valueMax: number }[];
|
||||
}
|
||||
).data;
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBe(58);
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it("overwrites min/max alongside the average on a re-post", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
hrBucketEntry(HR_BUCKET_ID, 78, { valueMin: 55, valueMax: 110 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
const data = (
|
||||
updateArg as {
|
||||
data: { value: number; valueMin: number; valueMax: number };
|
||||
}
|
||||
).data;
|
||||
expect(data.value).toBe(78);
|
||||
expect(data.valueMin).toBe(55);
|
||||
expect(data.valueMax).toBe(110);
|
||||
});
|
||||
|
||||
it("leaves min/max null on a per-sample PULSE row even if sent", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [hrBucketEntry(SAMPLE_ID, 64, { valueMin: 50, valueMax: 80 })],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: { valueMin: number | null; valueMax: number | null }[];
|
||||
}
|
||||
).data;
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-hour post.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
|
||||
@@ -124,6 +124,15 @@ const batchEntrySchema = z.object({
|
||||
// device's own verdict / severity; resolved to a `rhythmClassification`
|
||||
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
|
||||
categoryValue: z.number().int().min(0).max(20).optional(),
|
||||
// v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly
|
||||
// heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry
|
||||
// the hour's MIN / MAX so a client renders the intra-hour band without
|
||||
// re-uploading raw samples. Persisted ONLY on a well-formed
|
||||
// `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored
|
||||
// NULL) for every other entry. Optional + backward-compatible: a pre-W192
|
||||
// iOS build omits them and the bucket keeps the avg-only contract.
|
||||
valueMin: z.number().finite().optional(),
|
||||
valueMax: z.number().finite().optional(),
|
||||
externalId: z.string().min(1).max(120),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.8.6 W6 — optional per-entry source tag. Defaults to
|
||||
@@ -255,6 +264,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
endDate: entry.endDate,
|
||||
sleepStage: entry.sleepStage,
|
||||
categoryValue: entry.categoryValue,
|
||||
valueMin: entry.valueMin,
|
||||
valueMax: entry.valueMax,
|
||||
});
|
||||
|
||||
if (!mapped) {
|
||||
@@ -301,6 +312,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
|
||||
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
|
||||
// per-day cumulative `stats:` total, or a manual entry never carries a
|
||||
// spread, so we pin them to null there even if a client mis-sends the
|
||||
// fields — `value` stays the single source of truth for those rows.
|
||||
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
|
||||
const valueMin =
|
||||
isHrBucket && typeof mapped.valueMin === "number"
|
||||
? mapped.valueMin
|
||||
: null;
|
||||
const valueMax =
|
||||
isHrBucket && typeof mapped.valueMax === "number"
|
||||
? mapped.valueMax
|
||||
: null;
|
||||
|
||||
prepared.push({
|
||||
index,
|
||||
entry,
|
||||
@@ -308,6 +334,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
userId: user.id,
|
||||
type: mapped.type,
|
||||
value: mapped.value,
|
||||
valueMin,
|
||||
valueMax,
|
||||
unit: mapped.unit,
|
||||
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
|
||||
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
|
||||
@@ -505,6 +533,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
data: {
|
||||
value: p.row.value as number,
|
||||
// v1.19.2 (iOS #34 extension) — overwrite the per-bucket
|
||||
// spread alongside `value` so a within-hour re-post (the
|
||||
// running mean + range shift as more samples land) replaces
|
||||
// both. Null for every non-HR-bucket `stats:` overwrite.
|
||||
valueMin: p.row.valueMin as number | null,
|
||||
valueMax: p.row.valueMax as number | null,
|
||||
unit: p.row.unit as string,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
externalSourceVersion: p.row.externalSourceVersion as
|
||||
|
||||
@@ -134,6 +134,51 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.points[0].secondary).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces per-point min/max for kind=pulse (iOS #34 ext)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
// hourly HR bucket — average + spread
|
||||
{
|
||||
id: "p1",
|
||||
value: 72,
|
||||
measuredAt: new Date("2026-06-21T14:00:00.000Z"),
|
||||
valueMin: 58,
|
||||
valueMax: 96,
|
||||
},
|
||||
// per-sample PULSE row — no spread
|
||||
{
|
||||
id: "p2",
|
||||
value: 64,
|
||||
measuredAt: new Date("2026-06-21T15:00:00.000Z"),
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
},
|
||||
] as never);
|
||||
const res = await GET(req("kind=pulse&days=7"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
points: Array<{
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(body.data.points[0].valueMin).toBe(58);
|
||||
expect(body.data.points[0].valueMax).toBe(96);
|
||||
expect(body.data.points[1].valueMin).toBeNull();
|
||||
expect(body.data.points[1].valueMax).toBeNull();
|
||||
|
||||
// The pulse read selects the spread columns.
|
||||
const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0];
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMin,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMax,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
|
||||
// series must return ONE point per night carrying the TIME-ASLEEP
|
||||
|
||||
@@ -124,6 +124,15 @@ interface SeriesPoint {
|
||||
* the night's TIME-ASLEEP total.
|
||||
*/
|
||||
sleepStages?: Partial<Record<SleepStage, number>> | null;
|
||||
/**
|
||||
* v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an
|
||||
* aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm
|
||||
* and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row
|
||||
* (no bucket spread) and absent for every non-pulse kind, so a client can
|
||||
* render a low/high band only where the data supports it.
|
||||
*/
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
}
|
||||
|
||||
function stdDev(values: number[]): number {
|
||||
@@ -317,6 +326,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
} else {
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
// v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the
|
||||
// heart-rate kind so the chart can render a low/high band around the
|
||||
// hourly average. Only PULSE rows ever carry a non-null spread (the
|
||||
// hourly HR bucket); every other kind selects the bare value shape.
|
||||
const includeSpread = kind === "pulse";
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -325,7 +339,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
...(includeSpread ? { valueMin: true, valueMax: true } : {}),
|
||||
},
|
||||
});
|
||||
if (kind === "glucose") {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
|
||||
@@ -379,6 +398,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else if (includeSpread) {
|
||||
// v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket
|
||||
// spread. A per-sample PULSE row has null min/max; an hourly HR bucket
|
||||
// carries the hour's range around the average.
|
||||
points = rows.map((r) => {
|
||||
const row = r as typeof r & {
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
at: row.measuredAt.toISOString(),
|
||||
value: row.value,
|
||||
secondary: null,
|
||||
valueMin: row.valueMin ?? null,
|
||||
valueMax: row.valueMax ?? null,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
} from "@/lib/mood/create-from-telegram";
|
||||
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import {
|
||||
isTelegramCapturableType,
|
||||
logTelegramMeasurement,
|
||||
} from "@/lib/measurements/create-from-telegram";
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
@@ -776,13 +780,15 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
}
|
||||
} else if (data.startsWith("measure_done:")) {
|
||||
// v1.19.0 — mark a Vorsorge/measurement reminder done. No value
|
||||
// capture (restraint) — satisfy the cadence via the shared primitive.
|
||||
// v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the
|
||||
// cadence via the shared primitive.
|
||||
const reminderId = data.slice("measure_done:".length).trim();
|
||||
const reminder = await prisma.measurementReminder.findFirst({
|
||||
where: { id: reminderId, userId: user.id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
// v1.19.2 — the target metric; drives the optional numeric capture.
|
||||
measurementType: true,
|
||||
intervalDays: true,
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
@@ -809,6 +815,48 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
}
|
||||
|
||||
// v1.19.2 — optional numeric value capture. When the reminder names a
|
||||
// single-value metric (BP and free-text Vorsorge are excluded), offer a
|
||||
// force-reply prompt; a numeric reply is captured as a Measurement
|
||||
// (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS
|
||||
// reminder + user through a TelegramPromptContext keyed on the prompt
|
||||
// message id, the SAME strict chat/message binding the mood-note path
|
||||
// uses — the userId is never read from the inbound payload. Optional and
|
||||
// self-cleaning: ignoring the prompt leaves the cadence already
|
||||
// satisfied, and the prompt is swept on the ~30-min auto-delete.
|
||||
if (isTelegramCapturableType(reminder.measurementType)) {
|
||||
const valuePrompt = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
t("telegram.measureValuePrompt"),
|
||||
{
|
||||
replyMarkup: {
|
||||
force_reply: true,
|
||||
input_field_placeholder: t("telegram.buttonMeasureValue"),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (valuePrompt.ok && valuePrompt.messageId) {
|
||||
try {
|
||||
await prisma.telegramPromptContext.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
chatId,
|
||||
promptMsgId: valuePrompt.messageId,
|
||||
kind: "measure_value",
|
||||
refId: reminder.id,
|
||||
// Same ~30-min window as the deletion sweep; a later reply is
|
||||
// treated as expired.
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — without the context row the value simply can't bind.
|
||||
}
|
||||
await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]);
|
||||
}
|
||||
}
|
||||
} else if (data.startsWith("measure_later:")) {
|
||||
// v1.19.0 — postpone a measurement reminder by N minutes (one-shot
|
||||
// nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in
|
||||
@@ -892,7 +940,11 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
if (context && context.userId === user.id && context.kind === "mood_note") {
|
||||
if (
|
||||
context &&
|
||||
context.userId === user.id &&
|
||||
(context.kind === "mood_note" || context.kind === "measure_value")
|
||||
) {
|
||||
// Consume the context row regardless of outcome so a reply can't be
|
||||
// replayed onto the entry twice.
|
||||
await prisma.telegramPromptContext
|
||||
@@ -903,7 +955,9 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
t("telegram.moodNoteExpired"),
|
||||
context.kind === "measure_value"
|
||||
? t("telegram.measureValueExpired")
|
||||
: t("telegram.moodNoteExpired"),
|
||||
);
|
||||
const toDelete = [userMsgId, replyToId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -913,6 +967,49 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.kind === "measure_value") {
|
||||
// v1.19.2 — numeric value capture for a measurement reminder. The
|
||||
// reminder named on the context row carries the expected metric; the
|
||||
// userId is the chat-resolved one (never the payload). A non-numeric
|
||||
// / out-of-range reply gets a calm hint, not a write. The reply, the
|
||||
// prompt, and the confirmation all self-clean on the ~30-min sweep.
|
||||
const reminder = await prisma.measurementReminder.findFirst({
|
||||
where: { id: context.refId, userId: user.id, deletedAt: null },
|
||||
select: { measurementType: true },
|
||||
});
|
||||
let confirmation: string;
|
||||
if (!isTelegramCapturableType(reminder?.measurementType)) {
|
||||
confirmation = t("telegram.measurementNotFound");
|
||||
} else {
|
||||
const externalId = `telegram:measure:${chatId}:${replyToId}`.slice(
|
||||
0,
|
||||
120,
|
||||
);
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: user.id,
|
||||
type: reminder.measurementType,
|
||||
rawText: text,
|
||||
tz: user.timezone,
|
||||
externalId,
|
||||
});
|
||||
confirmation =
|
||||
result.status === "ok"
|
||||
? t("telegram.measureValueSaved")
|
||||
: result.status === "out_of_range"
|
||||
? t("telegram.measureValueOutOfRange")
|
||||
: t("telegram.measureValueInvalid");
|
||||
}
|
||||
const resp = await sendTelegramMessage(botToken, chatId, confirmation);
|
||||
const toDelete = [userMsgId, replyToId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
);
|
||||
if (toDelete.length > 0) {
|
||||
await scheduleAutoDelete(user.id, chatId, toDelete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const attached = await attachTelegramMoodNote({
|
||||
userId: user.id,
|
||||
moodEntryId: context.refId,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { measurement } = vi.hoisted(() => ({
|
||||
measurement: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addMeta: vi.fn() }),
|
||||
}));
|
||||
|
||||
import {
|
||||
logTelegramMeasurement,
|
||||
isTelegramCapturableType,
|
||||
parseTelegramNumber,
|
||||
} from "@/lib/measurements/create-from-telegram";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
measurement.findUnique.mockResolvedValue(null);
|
||||
measurement.create.mockResolvedValue({ id: "m-1" });
|
||||
});
|
||||
|
||||
describe("parseTelegramNumber", () => {
|
||||
it("parses an integer", () => {
|
||||
expect(parseTelegramNumber("72")).toBe(72);
|
||||
});
|
||||
it("parses a comma decimal (de-DE keyboard)", () => {
|
||||
expect(parseTelegramNumber("82,5")).toBe(82.5);
|
||||
});
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(parseTelegramNumber(" 98 ")).toBe(98);
|
||||
});
|
||||
it("rejects trailing unit noise", () => {
|
||||
expect(parseTelegramNumber("72 bpm")).toBeNull();
|
||||
});
|
||||
it("rejects non-numeric text", () => {
|
||||
expect(parseTelegramNumber("done")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTelegramCapturableType", () => {
|
||||
it("accepts single-value metrics", () => {
|
||||
expect(isTelegramCapturableType("WEIGHT")).toBe(true);
|
||||
expect(isTelegramCapturableType("BLOOD_GLUCOSE")).toBe(true);
|
||||
});
|
||||
it("rejects blood pressure (needs two values)", () => {
|
||||
expect(isTelegramCapturableType("BLOOD_PRESSURE_SYS")).toBe(false);
|
||||
});
|
||||
it("rejects null / free-text reminders", () => {
|
||||
expect(isTelegramCapturableType(null)).toBe(false);
|
||||
expect(isTelegramCapturableType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logTelegramMeasurement", () => {
|
||||
it("writes a Measurement with source=TELEGRAM and the canonical unit", async () => {
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
rawText: "82,5",
|
||||
tz: "Europe/Berlin",
|
||||
externalId: "telegram:measure:7777:555",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(measurement.create).toHaveBeenCalledTimes(1);
|
||||
const data = measurement.create.mock.calls[0][0].data;
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.type).toBe("WEIGHT");
|
||||
expect(data.value).toBe(82.5);
|
||||
expect(data.source).toBe("TELEGRAM");
|
||||
expect(data.unit).toBe("kg");
|
||||
expect(data.externalId).toBe("telegram:measure:7777:555");
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-numeric reply without writing", async () => {
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
rawText: "heavy",
|
||||
tz: null,
|
||||
externalId: "telegram:measure:7777:556",
|
||||
});
|
||||
expect(result.status).toBe("invalid_number");
|
||||
expect(measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an out-of-range value without writing", async () => {
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
rawText: "999",
|
||||
tz: null,
|
||||
externalId: "telegram:measure:7777:557",
|
||||
});
|
||||
expect(result.status).toBe("out_of_range");
|
||||
expect(measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unsupported (BP) type", async () => {
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: "user-1",
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
rawText: "120",
|
||||
tz: null,
|
||||
externalId: "telegram:measure:7777:558",
|
||||
});
|
||||
expect(result.status).toBe("unsupported_type");
|
||||
expect(measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent on a redelivered reply (externalId hit)", async () => {
|
||||
measurement.findUnique.mockResolvedValue({ id: "existing" });
|
||||
const result = await logTelegramMeasurement({
|
||||
userId: "user-1",
|
||||
type: "PULSE",
|
||||
rawText: "60",
|
||||
tz: null,
|
||||
externalId: "telegram:measure:7777:559",
|
||||
});
|
||||
expect(result.status).toBe("ok");
|
||||
expect(measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.19.2 — `User.telegramChatId` must stay UNIQUE. The inbound webhook
|
||||
* resolves the user from the chat id; a chat shared across two accounts
|
||||
* would route a reply / button tap / numeric capture ambiguously. The
|
||||
* constraint is the structural guard, so this asserts it never silently
|
||||
* drops out of the schema on a future regeneration.
|
||||
*/
|
||||
const SCHEMA_PATH = join(__dirname, "../../../..", "prisma", "schema.prisma");
|
||||
|
||||
describe("User.telegramChatId schema invariant", () => {
|
||||
const schema = readFileSync(SCHEMA_PATH, "utf8");
|
||||
|
||||
it("declares the column @unique", () => {
|
||||
const line = schema.split("\n").find((l) => l.includes("telegramChatId"));
|
||||
expect(line).toBeDefined();
|
||||
expect(line).toMatch(/@unique/);
|
||||
});
|
||||
|
||||
it("ships the 0189 unique-index migration", () => {
|
||||
const migrationPath = join(
|
||||
__dirname,
|
||||
"../../../..",
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0189_v1192_telegram_chat_unique_and_source",
|
||||
"migration.sql",
|
||||
);
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
expect(sql).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS/);
|
||||
expect(sql).toMatch(/users_telegram_chat_id_key/);
|
||||
});
|
||||
});
|
||||
@@ -1082,6 +1082,16 @@ export interface AppleHealthEntryInput {
|
||||
* existing category-value slot still round-trips.
|
||||
*/
|
||||
categoryValue?: number;
|
||||
/**
|
||||
* v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated
|
||||
* heart-rate row. The hour's MIN / MAX bpm in Apple's native unit
|
||||
* (pre-conversion), companions to `value` (the hour's AVERAGE). The
|
||||
* mapper applies the SAME `convertToDbUnit` it applies to `value`.
|
||||
* Ignored for every non-quantity / non-HR-bucket entry; the caller
|
||||
* persists them only on the hourly HR bucket row.
|
||||
*/
|
||||
valueMin?: number;
|
||||
valueMax?: number;
|
||||
}
|
||||
|
||||
/** Output of `mapAppleHealthEntry()`. */
|
||||
@@ -1090,6 +1100,13 @@ export interface AppleHealthEntryOutput {
|
||||
value: number;
|
||||
unit: string;
|
||||
takenAt: Date;
|
||||
/**
|
||||
* v1.19.2 (iOS #34 extension) — converted per-bucket MIN / MAX, set
|
||||
* only when the input carried them (the hourly HR bucket). NULL/absent
|
||||
* for every other row.
|
||||
*/
|
||||
valueMin?: number;
|
||||
valueMax?: number;
|
||||
/** Set only for sleep entries. */
|
||||
sleepStage?: SleepStage;
|
||||
/**
|
||||
@@ -1132,6 +1149,17 @@ export function mapAppleHealthEntry(
|
||||
takenAt,
|
||||
};
|
||||
|
||||
// v1.19.2 (iOS #34 extension) — carry the per-bucket MIN / MAX through
|
||||
// the SAME unit conversion as `value` when the caller supplied them (the
|
||||
// hourly HR bucket). A finite check guards a malformed payload; the
|
||||
// caller only persists these on the HR-bucket row.
|
||||
if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) {
|
||||
out.valueMin = mapping.convertToDbUnit(input.valueMin);
|
||||
}
|
||||
if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) {
|
||||
out.valueMax = mapping.convertToDbUnit(input.valueMax);
|
||||
}
|
||||
|
||||
if (mapping.sleepStageMap) {
|
||||
if (input.sleepStage === undefined) return null;
|
||||
const stage = mapping.sleepStageMap[input.sleepStage];
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* v1.19.2 — server-authoritative measurement capture from the Telegram bot.
|
||||
*
|
||||
* The HTTP measurement-create path (`POST /api/measurements`) is bound to a
|
||||
* cookie/Bearer session via `requireAuth`; the Telegram webhook resolves the
|
||||
* user from the linked chat instead. This helper holds the shared write so
|
||||
* the two entry points agree on the canonical shape: the same range guard,
|
||||
* the same canonical unit, the same cache invalidation + DAY-rollup
|
||||
* recompute the manual route fires. `source` is pinned to the `TELEGRAM`
|
||||
* `MeasurementSource` value (added in migration 0189).
|
||||
*
|
||||
* The `userId` is NEVER taken from the Telegram payload — the caller passes
|
||||
* the id it resolved from the stored `telegramChatId` binding. The capture
|
||||
* is correlated to a specific `MeasurementReminder` (carrying the expected
|
||||
* `measurementType`) through a `TelegramPromptContext` row, so a numeric
|
||||
* reply can only ever land the metric that reminder asked for, for that
|
||||
* user. BP (two values) is out of scope for the single-numeric-reply path.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import {
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* The single-value measurement types a numeric Telegram reply can capture.
|
||||
*
|
||||
* Excludes BLOOD_PRESSURE_SYS — a BP reading needs systolic AND diastolic,
|
||||
* which a single numeric reply can't express unambiguously. The BP reminder
|
||||
* keeps its existing satisfy-only "done" flow; a value capture for it is a
|
||||
* documented follow-up (a guided two-number prompt).
|
||||
*/
|
||||
const TELEGRAM_CAPTURABLE_TYPES: ReadonlySet<MeasurementType> =
|
||||
new Set<MeasurementType>([
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
"BODY_FAT",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"BONE_MASS",
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when a numeric Telegram reply can capture a Measurement for this
|
||||
* reminder's target type. The webhook gates the value-prompt on this so a
|
||||
* BP reminder (or a free-text Vorsorge with no `measurementType`) never
|
||||
* offers an ambiguous numeric capture.
|
||||
*/
|
||||
export function isTelegramCapturableType(
|
||||
type: MeasurementType | null | undefined,
|
||||
): type is MeasurementType {
|
||||
return type != null && TELEGRAM_CAPTURABLE_TYPES.has(type);
|
||||
}
|
||||
|
||||
export interface TelegramMeasurementResult {
|
||||
/** `ok` — a row was written (or already existed under the dedup key).
|
||||
* `invalid_number` — the reply did not parse to a finite number.
|
||||
* `out_of_range` — the value fell outside the type's plausible range.
|
||||
* `unsupported_type` — the type is not single-value capturable. */
|
||||
status: "ok" | "invalid_number" | "out_of_range" | "unsupported_type";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a free-text Telegram reply into a finite number. Tolerates a
|
||||
* comma decimal separator (de-DE keyboards) and surrounding whitespace;
|
||||
* rejects anything with trailing non-numeric noise so "72 bpm" doesn't
|
||||
* silently capture 72.
|
||||
*/
|
||||
export function parseTelegramNumber(text: string): number | null {
|
||||
const normalised = text.trim().replace(",", ".");
|
||||
if (!/^-?\d+(\.\d+)?$/.test(normalised)) return null;
|
||||
const n = Number(normalised);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a measurement on behalf of a Telegram-linked user from a numeric
|
||||
* reply to a measurement-reminder prompt.
|
||||
*
|
||||
* Idempotent on `externalId` (the per-prompt key the caller derives from
|
||||
* `telegram:measure:<chatId>:<promptMsgId>`): a redelivered reply converges
|
||||
* onto the same row via the `(userId, type, source, externalId)` unique
|
||||
* instead of minting a duplicate.
|
||||
*/
|
||||
export async function logTelegramMeasurement(input: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
rawText: string;
|
||||
tz: string | null;
|
||||
/** Stable per-prompt id for Telegram redelivery dedup. */
|
||||
externalId: string;
|
||||
client?: PrismaClient | Prisma.TransactionClient;
|
||||
}): Promise<TelegramMeasurementResult> {
|
||||
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
|
||||
|
||||
if (!isTelegramCapturableType(input.type)) {
|
||||
return { status: "unsupported_type" };
|
||||
}
|
||||
|
||||
const value = parseTelegramNumber(input.rawText);
|
||||
if (value === null) {
|
||||
return { status: "invalid_number" };
|
||||
}
|
||||
|
||||
if (validateMeasurementRange(input.type, value) !== null) {
|
||||
return { status: "out_of_range" };
|
||||
}
|
||||
|
||||
const measuredAt = new Date();
|
||||
|
||||
const existing = await prisma.measurement.findUnique({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
source: "TELEGRAM",
|
||||
externalId: input.externalId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
value,
|
||||
unit: getUnitForType(input.type),
|
||||
source: "TELEGRAM",
|
||||
measuredAt,
|
||||
externalId: input.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
invalidateUserMeasurements(input.userId, { evict: true });
|
||||
|
||||
// Best-effort rollup refresh — a cache tier, never a write-path invariant.
|
||||
try {
|
||||
await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt);
|
||||
} catch (rollupErr) {
|
||||
getEvent()?.addMeta(
|
||||
"telegram_measurement_rollup_failed",
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
);
|
||||
}
|
||||
|
||||
return { status: "ok" };
|
||||
}
|
||||
@@ -30,7 +30,21 @@ const batchEntrySchema = z
|
||||
.number()
|
||||
.finite()
|
||||
.describe(
|
||||
"Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max are a documented fast-follow.",
|
||||
"Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.",
|
||||
),
|
||||
valueMin: z
|
||||
.number()
|
||||
.finite()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
|
||||
),
|
||||
valueMax: z
|
||||
.number()
|
||||
.finite()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
|
||||
),
|
||||
unit: z.string().min(1).max(60),
|
||||
startDate: z.iso.datetime({ offset: true }),
|
||||
@@ -407,6 +421,20 @@ const seriesPointSchema = z.object({
|
||||
.describe(
|
||||
"Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.",
|
||||
),
|
||||
valueMin: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind.",
|
||||
),
|
||||
valueMax: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesResponse = z
|
||||
|
||||
@@ -177,6 +177,13 @@ export const measurementSourceEnum = z.enum([
|
||||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"OURA",
|
||||
// v1.19.2 — Telegram numeric-reply capture. A reply to a measurement
|
||||
// reminder is written server-side from the chat-bound webhook (not the
|
||||
// cookie/Bearer client write path), so it carries its own source label.
|
||||
// Part of this enum so the read/response shapes (and the iOS decoder) can
|
||||
// decode the rows it surfaces; the client-facing write surfaces still
|
||||
// reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist).
|
||||
"TELEGRAM",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user