From 868dfb72bd797a8bb1dde7d52a6aaab202da06a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:09 +0200 Subject: [PATCH 1/3] feat(measurements): add per-bucket heart-rate min/max to the hourly contract The v1.19.0 hourly heart-rate bucket (`stats:HKQuantityTypeIdentifierHeartRate:`) shipped avg-only: one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a client can render the intra-hour range without re-uploading raw samples. - Two nullable columns on `measurements` (`value_min` / `value_max`), migration 0188. Set only on a well-formed hourly HR bucket row; null for per-sample readings, per-day cumulative totals, and manual entries. - Batch ingest threads the spread through the same unit conversion as the average and persists / overwrites it alongside `value` on the bucket row, pinning it null everywhere else. - The series reader returns the spread for `kind=pulse` (null on a per-sample PULSE row); the OpenAPI Zod contract documents both fields on the batch entry and the series point. The avg path is unchanged. --- .../migration.sql | 33 +++++++++ prisma/schema.prisma | 10 +++ .../batch/__tests__/hourly-hr-wire.test.ts | 70 ++++++++++++++++++- src/app/api/measurements/batch/route.ts | 34 +++++++++ .../series/__tests__/route.test.ts | 45 ++++++++++++ src/app/api/measurements/series/route.ts | 39 ++++++++++- src/lib/measurements/apple-health-mapping.ts | 28 ++++++++ src/lib/openapi/routes/measurements.ts | 30 +++++++- 8 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql diff --git a/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql new file mode 100644 index 000000000..e143e9af6 --- /dev/null +++ b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql @@ -0,0 +1,33 @@ +-- v1.19.2 (iOS #34 extension) — per-bucket heart-rate spread. +-- +-- v1.19.0 shipped the hourly heart-rate bucket +-- (`stats:HKQuantityTypeIdentifierHeartRate:`) carrying the +-- hour's AVERAGE bpm as one PULSE row. This adds the hour's MIN / MAX bpm +-- as two companion columns so a client can render the intra-hour range +-- (low/high band around the mean) without iOS re-uploading raw samples. +-- +-- Two additive, nullable columns on `measurements`: +-- +-- * `value_min` — the bucket's minimum bpm. +-- * `value_max` — the bucket's maximum bpm. +-- +-- Set ONLY on the hourly heart-rate bucket rows; NULL for every other row +-- (per-sample readings, per-day cumulative `stats:` totals, manual +-- entries). The series reader hands them through only for the heart-rate +-- kind, and the batch ingest persists / overwrites them alongside `value`. +-- +-- Additive; no existing row touched. The columns default to NULL so every +-- legacy row and every non-HR-bucket write stays unchanged. Idempotent +-- guards (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_min"; +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_max"; +-- The columns are read-additive — dropping them reverts to the avg-only +-- v1.19.0 contract. + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_min" DOUBLE PRECISION; + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_max" DOUBLE PRECISION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ffce14c..54d7485f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1026,6 +1026,16 @@ model Measurement { userId String @map("user_id") type MeasurementType value Float + // v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row. + // Set ONLY on the hourly heart-rate bucket + // (`stats:HKQuantityTypeIdentifierHeartRate:`), where `value` + // is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a + // client can render the intra-hour range without re-uploading raw + // samples. NULL for every other row (per-sample readings, per-day + // cumulative `stats:` totals, manual entries) — the series reader hands + // them through only for the heart-rate kind. + valueMin Float? @map("value_min") + valueMax Float? @map("value_max") unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius source MeasurementSource @default(MANUAL) measuredAt DateTime @map("measured_at") diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts index 09f6ceaad..839e85d2f 100644 --- a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -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([ diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index d56a1d236..bb9e9b1d6 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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:` 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 { 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 { 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 { 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 { }, 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 diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 7e8c4d0d4..e4070e049 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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 }).select.valueMin, + ).toBe(true); + expect( + (selectArg as { select: Record }).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 diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 505bbc6a6..23f16bc75 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -124,6 +124,15 @@ interface SeriesPoint { * the night's TIME-ASLEEP total. */ sleepStages?: Partial> | 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, diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 866ecc5c6..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -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]; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9af53ec26..0c2a70172 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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:` 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:` 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 From 5c0583133dc2715dfe69c49188a4b99ae2a48538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:17 +0200 Subject: [PATCH 2/3] chore(db): make the 0187 telegram-prompt-context migration rerun-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard the CREATE TABLE + both CREATE INDEX statements with IF NOT EXISTS so a re-run on a database that already has the table is a no-op, matching the guard convention every other recent migration ships with. Semantics are unchanged — same table, same columns, same indexes. --- .../0187_v1190_telegram_prompt_context/migration.sql | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql index aab9f45f5..3f8c027f2 100644 --- a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql +++ b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql @@ -1,6 +1,10 @@ -- v1.19.0 — force-reply correlation for the interactive mood reminder. -- Maps a bot prompt message to the entry a free-text reply attaches to. -CREATE TABLE "telegram_prompt_contexts" ( +-- +-- Idempotent guards (`IF NOT EXISTS`) so a rerun is safe on prod. The +-- semantics are unchanged from the original cut (same table, same columns, +-- same indexes) — v1.19.2 added only the guards. +CREATE TABLE IF NOT EXISTS "telegram_prompt_contexts" ( "id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "chat_id" TEXT NOT NULL, @@ -14,7 +18,7 @@ CREATE TABLE "telegram_prompt_contexts" ( ); -- CreateIndex -CREATE UNIQUE INDEX "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); -- CreateIndex -CREATE INDEX "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); +CREATE INDEX IF NOT EXISTS "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); From 9c8fb8c7c88722517165ac7f52fafb2c409bd9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:42 +0200 Subject: [PATCH 3/3] feat(telegram): unique chat binding and numeric measurement capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes to the Telegram bot's data path. One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration 0189, defensive pre-clean of any pre-existing duplicate binding). The inbound webhook resolves the user from the chat id, so a chat shared across two accounts would route a reply or button tap ambiguously. The index is over a nullable column, so an unlinked account (null chat id) never collides — only two accounts binding the same non-null chat id are rejected. Numeric measurement capture. A numeric reply to a measurement reminder is now captured as a Measurement (`source = TELEGRAM`, added to the enum in 0189). On "Erledigt" for a reminder that names a single-value metric, the bot offers an optional value prompt via force_reply; a numeric reply is written through the shared `logTelegramMeasurement` helper — same range guard, canonical unit, cache + rollup refresh as the manual route. The reply binds to the reminder and 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 payload. Comma decimals are tolerated; non-numeric and out-of-range replies get a calm hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood pressure (two values) is out of scope for the single-numeric path and keeps its satisfy-only flow. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../migration.sql | 55 ++++++ prisma/schema.prisma | 15 +- src/app/api/telegram/webhook/route.ts | 105 ++++++++++- .../__tests__/create-from-telegram.test.ts | 143 +++++++++++++++ .../__tests__/telegram-chat-id-unique.test.ts | 36 ++++ src/lib/measurements/create-from-telegram.ts | 164 ++++++++++++++++++ src/lib/validations/measurement.ts | 7 + 13 files changed, 562 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql create mode 100644 src/lib/measurements/__tests__/create-from-telegram.test.ts create mode 100644 src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts create mode 100644 src/lib/measurements/create-from-telegram.ts diff --git a/messages/de.json b/messages/de.json index c15520593..56b3efe85 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", - "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen" + "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen", + "measureValuePrompt": "Antworte mit dem Wert, um ihn zu speichern (optional).", + "buttonMeasureValue": "Wert…", + "measureValueSaved": "Messwert gespeichert.", + "measureValueInvalid": "Antworte mit einer Zahl, um einen Messwert zu speichern.", + "measureValueOutOfRange": "Dieser Wert liegt außerhalb des plausiblen Bereichs.", + "measureValueExpired": "Diese Aufforderung ist abgelaufen." }, "medicationReminders": { "phaseGreenTitle": "🟢 Erinnerung: {medName}", diff --git a/messages/en.json b/messages/en.json index 4ad161b92..a0ffb77bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Please specify a medication, e.g. taken Ramipril.", "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", "helpHeader": "Available commands:", - "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake" + "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake", + "measureValuePrompt": "Reply with the value to log it (optional).", + "buttonMeasureValue": "Value…", + "measureValueSaved": "Reading saved.", + "measureValueInvalid": "Reply with a number to log a reading.", + "measureValueOutOfRange": "That value is outside the plausible range.", + "measureValueExpired": "That prompt has expired." }, "medicationReminders": { "phaseGreenTitle": "🟢 Reminder: {medName}", diff --git a/messages/es.json b/messages/es.json index 66d9d2421..6ca4e968a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indica el medicamento, p. ej. tomado Ramipril.", "medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.", "helpHeader": "Comandos disponibles:", - "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida" + "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida", + "measureValuePrompt": "Responde con el valor para registrarlo (opcional).", + "buttonMeasureValue": "Valor…", + "measureValueSaved": "Lectura guardada.", + "measureValueInvalid": "Responde con un número para registrar una lectura.", + "measureValueOutOfRange": "Ese valor está fuera del rango plausible.", + "measureValueExpired": "Esa solicitud ha caducado." }, "medicationReminders": { "phaseGreenTitle": "🟢 Recordatorio: {medName}", diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..e1cc7a8c5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indique le médicament, p. ex. pris Ramipril.", "medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.", "helpHeader": "Commandes disponibles :", - "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée" + "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée", + "measureValuePrompt": "Répondez avec la valeur pour l’enregistrer (facultatif).", + "buttonMeasureValue": "Valeur…", + "measureValueSaved": "Mesure enregistrée.", + "measureValueInvalid": "Répondez avec un nombre pour enregistrer une mesure.", + "measureValueOutOfRange": "Cette valeur est en dehors de la plage plausible.", + "measureValueExpired": "Cette invite a expiré." }, "medicationReminders": { "phaseGreenTitle": "🟢 Rappel : {medName}", diff --git a/messages/it.json b/messages/it.json index 226eaebb0..58777009b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indica il farmaco, es. preso Ramipril.", "medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.", "helpHeader": "Comandi disponibili:", - "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata" + "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata", + "measureValuePrompt": "Rispondi con il valore per registrarlo (facoltativo).", + "buttonMeasureValue": "Valore…", + "measureValueSaved": "Misurazione salvata.", + "measureValueInvalid": "Rispondi con un numero per registrare una misurazione.", + "measureValueOutOfRange": "Questo valore è fuori dall’intervallo plausibile.", + "measureValueExpired": "Questo messaggio è scaduto." }, "medicationReminders": { "phaseGreenTitle": "🟢 Promemoria: {medName}", diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..ce746d194 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Podaj lek, np. przyjęty Ramipril.", "medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.", "helpHeader": "Dostępne polecenia:", - "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie" + "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie", + "measureValuePrompt": "Odpowiedz wartością, aby ją zapisać (opcjonalnie).", + "buttonMeasureValue": "Wartość…", + "measureValueSaved": "Pomiar zapisany.", + "measureValueInvalid": "Odpowiedz liczbą, aby zapisać pomiar.", + "measureValueOutOfRange": "Ta wartość jest poza wiarygodnym zakresem.", + "measureValueExpired": "Ten monit wygasł." }, "medicationReminders": { "phaseGreenTitle": "🟢 Przypomnienie: {medName}", diff --git a/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql new file mode 100644 index 000000000..44cc79f90 --- /dev/null +++ b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql @@ -0,0 +1,55 @@ +-- v1.19.2 — Telegram: one chat ⇄ one account, plus a TELEGRAM measurement +-- source for numeric-reply capture. +-- +-- 1. `measurement_source` += `TELEGRAM`. A numeric reply to a measurement +-- reminder is captured as a Measurement that enters through the +-- chat-bound webhook (not the cookie/Bearer client write path), so it +-- carries its own source label. Purely-additive enum extension; no row +-- touched. `ADD VALUE IF NOT EXISTS` makes the rerun safe. The new value +-- is NOT used elsewhere in this migration, so it is safe to extend the +-- enum in the same step (Postgres only forbids USING a freshly-added +-- value in the same transaction). Mirrors the 0160 source-extension +-- precedent. +-- +-- 2. `users.telegram_chat_id` UNIQUE. The inbound webhook resolves the user +-- from `telegram_chat_id` (`findTelegramUser`); a chat id shared across +-- two accounts would route a reply / button tap / numeric capture +-- ambiguously. The index is over a nullable column, so Postgres' default +-- NULL-distinct semantics keep an unlinked account (NULL chat id) from +-- ever colliding — only two accounts both binding the SAME non-NULL chat +-- id are rejected, which is the bug we are closing. +-- +-- Defensive pre-clean: should any duplicate non-NULL chat ids already +-- exist (a chat re-pointed between accounts before this constraint +-- landed), keep the most recently updated row's binding and NULL out the +-- stale ones so the unique index can be built without error. The cleared +-- accounts simply fall back to "Telegram not linked" and re-link from +-- Settings; no data beyond the binding is touched. +-- +-- Idempotent guards (`IF NOT EXISTS`) make a rerun safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so `TELEGRAM` stays +-- (inert with no rows). The unique index drops with +-- `DROP INDEX IF EXISTS "users_telegram_chat_id_key"`. + +-- ── 1. measurement_source — append the user-driven webhook source ────── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'TELEGRAM'; + +-- ── 2. users.telegram_chat_id — one chat binds one account ───────────── +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY telegram_chat_id + ORDER BY updated_at DESC, id DESC + ) AS rn + FROM "users" + WHERE telegram_chat_id IS NOT NULL +) +UPDATE "users" u +SET telegram_chat_id = NULL +FROM ranked +WHERE u.id = ranked.id AND ranked.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_telegram_chat_id_key" + ON "users" ("telegram_chat_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 54d7485f1..e6839cdce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,7 +94,12 @@ model User { // Telegram notifications (per-user) telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted - telegramChatId String? @map("telegram_chat_id") + // v1.19.2 — one Telegram chat binds to exactly one HealthLog account. + // The inbound webhook resolves the user from this chat id; a chat shared + // across two accounts would route a reply / button tap ambiguously. The + // unique is partial by Postgres NULL semantics (multiple NULLs allowed), + // so an unlinked account (NULL chat id) never collides. Migration 0189. + telegramChatId String? @unique @map("telegram_chat_id") telegramEnabled Boolean @default(false) @map("telegram_enabled") // Withings OAuth credentials (per-user, encrypted) @@ -1002,6 +1007,14 @@ enum MeasurementSource { // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. OURA + // v1.19.2 — measurements captured from a numeric reply to a Telegram + // measurement reminder. Unlike the server-owned integrations above, this + // is a user-driven write, but it enters through the webhook (chat-bound) + // rather than the cookie/Bearer client write path, so it carries its own + // source label. The client batch + single-POST surfaces still reject it + // (server-resolved only); iOS DTO mirrors this spelling so the + // /api/sync/changes delta feed decodes the rows. + TELEGRAM @@map("measurement_source") } diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 5c25ed80e..7de67e696 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -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, diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts new file mode 100644 index 000000000..7dfdf3141 --- /dev/null +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -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(); + }); +}); diff --git a/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts new file mode 100644 index 000000000..67db1f4a4 --- /dev/null +++ b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts @@ -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/); + }); +}); diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts new file mode 100644 index 000000000..8b415e253 --- /dev/null +++ b/src/lib/measurements/create-from-telegram.ts @@ -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 = + new Set([ + "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::`): 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 { + 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" }; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 9d3197295..991094226 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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", ]); /**