diff --git a/messages/de.json b/messages/de.json
index 58958abea..e5629cc51 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5955,7 +5955,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 ac0ecf87b..a44932a9f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5955,7 +5955,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 244a86a3e..e8f6b9456 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5955,7 +5955,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 9cafc0164..1c58b4827 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5955,7 +5955,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 49df1baf4..6778c0ec7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5955,7 +5955,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 6328e81c1..638f8a520 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5955,7 +5955,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/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");
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/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 e1ffce14c..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")
}
@@ -1026,6 +1039,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/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/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/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/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
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",
]);
/**