feat(telegram): unique chat binding and numeric measurement capture

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 21:06:42 +02:00
1 parent 5c0583133d
commit 9c8fb8c7c8
13 files changed
+562 -11

No files matched your search

+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
"helpHeader": "<b>Verfügbare Befehle:</b>",
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\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\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\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}",
+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Please specify a medication, e.g. <b>taken Ramipril</b>.",
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
"helpHeader": "<b>Available commands:</b>",
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\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\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\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}",
+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Indica el medicamento, p. ej. <b>tomado Ramipril</b>.",
"medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.",
"helpHeader": "<b>Comandos disponibles:</b>",
"helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\n<b>Comandos de texto:</b>\ntaken &lt;nombre&gt; — confirmar una toma\n\n<b>Botones en los recordatorios:</b>\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\n<b>Comandos de texto:</b>\ntaken &lt;nombre&gt; — confirmar una toma\n\n<b>Botones en los recordatorios:</b>\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}",
+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Indique le médicament, p. ex. <b>pris Ramipril</b>.",
"medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.",
"helpHeader": "<b>Commandes disponibles :</b>",
"helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\n<b>Commandes texte :</b>\ntaken &lt;nom&gt; — confirmer une prise\n\n<b>Boutons dans les rappels :</b>\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\n<b>Commandes texte :</b>\ntaken &lt;nom&gt; — confirmer une prise\n\n<b>Boutons dans les rappels :</b>\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}",
+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Indica il farmaco, es. <b>preso Ramipril</b>.",
"medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.",
"helpHeader": "<b>Comandi disponibili:</b>",
"helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\n<b>Comandi testuali:</b>\ntaken &lt;nome&gt; — conferma un'assunzione\n\n<b>Pulsanti nei promemoria:</b>\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\n<b>Comandi testuali:</b>\ntaken &lt;nome&gt; — conferma un'assunzione\n\n<b>Pulsanti nei promemoria:</b>\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}",
+7 -1
View File
@@ -5939,7 +5939,13 @@
"askMedicationName": "Podaj lek, np. <b>przyjęty Ramipril</b>.",
"medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.",
"helpHeader": "<b>Dostępne polecenia:</b>",
"helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\n<b>Polecenia tekstowe:</b>\ntaken &lt;nazwa&gt; — potwierdź przyjęcie\n\n<b>Przyciski w przypomnieniach:</b>\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\n<b>Polecenia tekstowe:</b>\ntaken &lt;nazwa&gt; — potwierdź przyjęcie\n\n<b>Przyciski w przypomnieniach:</b>\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}",
@@ -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");
+14 -1
View File
@@ -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")
}
+101 -4
View File
@@ -33,6 +33,10 @@ import {
} from "@/lib/mood/create-from-telegram";
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import {
isTelegramCapturableType,
logTelegramMeasurement,
} from "@/lib/measurements/create-from-telegram";
interface TelegramUpdate {
update_id: number;
@@ -776,13 +780,15 @@ async function handleCallback(update: TelegramUpdate) {
await deleteMessage(botToken, chatId, messageId);
}
} else if (data.startsWith("measure_done:")) {
// v1.19.0 — mark a Vorsorge/measurement reminder done. No value
// capture (restraint) — satisfy the cadence via the shared primitive.
// v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the
// cadence via the shared primitive.
const reminderId = data.slice("measure_done:".length).trim();
const reminder = await prisma.measurementReminder.findFirst({
where: { id: reminderId, userId: user.id, deletedAt: null },
select: {
id: true,
// v1.19.2 — the target metric; drives the optional numeric capture.
measurementType: true,
intervalDays: true,
rrule: true,
anchorDate: true,
@@ -809,6 +815,48 @@ async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
// v1.19.2 — optional numeric value capture. When the reminder names a
// single-value metric (BP and free-text Vorsorge are excluded), offer a
// force-reply prompt; a numeric reply is captured as a Measurement
// (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS
// reminder + user through a TelegramPromptContext keyed on the prompt
// message id, the SAME strict chat/message binding the mood-note path
// uses — the userId is never read from the inbound payload. Optional and
// self-cleaning: ignoring the prompt leaves the cadence already
// satisfied, and the prompt is swept on the ~30-min auto-delete.
if (isTelegramCapturableType(reminder.measurementType)) {
const valuePrompt = await sendTelegramMessage(
botToken,
chatId,
t("telegram.measureValuePrompt"),
{
replyMarkup: {
force_reply: true,
input_field_placeholder: t("telegram.buttonMeasureValue"),
},
},
);
if (valuePrompt.ok && valuePrompt.messageId) {
try {
await prisma.telegramPromptContext.create({
data: {
userId: user.id,
chatId,
promptMsgId: valuePrompt.messageId,
kind: "measure_value",
refId: reminder.id,
// Same ~30-min window as the deletion sweep; a later reply is
// treated as expired.
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
},
});
} catch {
// Best-effort — without the context row the value simply can't bind.
}
await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]);
}
}
} else if (data.startsWith("measure_later:")) {
// v1.19.0 — postpone a measurement reminder by N minutes (one-shot
// nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in
@@ -892,7 +940,11 @@ async function handleTextMessage(update: TelegramUpdate) {
expiresAt: true,
},
});
if (context && context.userId === user.id && context.kind === "mood_note") {
if (
context &&
context.userId === user.id &&
(context.kind === "mood_note" || context.kind === "measure_value")
) {
// Consume the context row regardless of outcome so a reply can't be
// replayed onto the entry twice.
await prisma.telegramPromptContext
@@ -903,7 +955,9 @@ async function handleTextMessage(update: TelegramUpdate) {
const resp = await sendTelegramMessage(
botToken,
chatId,
t("telegram.moodNoteExpired"),
context.kind === "measure_value"
? t("telegram.measureValueExpired")
: t("telegram.moodNoteExpired"),
);
const toDelete = [userMsgId, replyToId, resp.messageId].filter(
(id): id is number => id != null,
@@ -913,6 +967,49 @@ async function handleTextMessage(update: TelegramUpdate) {
}
return;
}
if (context.kind === "measure_value") {
// v1.19.2 — numeric value capture for a measurement reminder. The
// reminder named on the context row carries the expected metric; the
// userId is the chat-resolved one (never the payload). A non-numeric
// / out-of-range reply gets a calm hint, not a write. The reply, the
// prompt, and the confirmation all self-clean on the ~30-min sweep.
const reminder = await prisma.measurementReminder.findFirst({
where: { id: context.refId, userId: user.id, deletedAt: null },
select: { measurementType: true },
});
let confirmation: string;
if (!isTelegramCapturableType(reminder?.measurementType)) {
confirmation = t("telegram.measurementNotFound");
} else {
const externalId = `telegram:measure:${chatId}:${replyToId}`.slice(
0,
120,
);
const result = await logTelegramMeasurement({
userId: user.id,
type: reminder.measurementType,
rawText: text,
tz: user.timezone,
externalId,
});
confirmation =
result.status === "ok"
? t("telegram.measureValueSaved")
: result.status === "out_of_range"
? t("telegram.measureValueOutOfRange")
: t("telegram.measureValueInvalid");
}
const resp = await sendTelegramMessage(botToken, chatId, confirmation);
const toDelete = [userMsgId, replyToId, resp.messageId].filter(
(id): id is number => id != null,
);
if (toDelete.length > 0) {
await scheduleAutoDelete(user.id, chatId, toDelete);
}
return;
}
const attached = await attachTelegramMoodNote({
userId: user.id,
moodEntryId: context.refId,
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { measurement } = vi.hoisted(() => ({
measurement: {
findUnique: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement },
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addMeta: vi.fn() }),
}));
import {
logTelegramMeasurement,
isTelegramCapturableType,
parseTelegramNumber,
} from "@/lib/measurements/create-from-telegram";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
beforeEach(() => {
vi.clearAllMocks();
measurement.findUnique.mockResolvedValue(null);
measurement.create.mockResolvedValue({ id: "m-1" });
});
describe("parseTelegramNumber", () => {
it("parses an integer", () => {
expect(parseTelegramNumber("72")).toBe(72);
});
it("parses a comma decimal (de-DE keyboard)", () => {
expect(parseTelegramNumber("82,5")).toBe(82.5);
});
it("trims surrounding whitespace", () => {
expect(parseTelegramNumber(" 98 ")).toBe(98);
});
it("rejects trailing unit noise", () => {
expect(parseTelegramNumber("72 bpm")).toBeNull();
});
it("rejects non-numeric text", () => {
expect(parseTelegramNumber("done")).toBeNull();
});
});
describe("isTelegramCapturableType", () => {
it("accepts single-value metrics", () => {
expect(isTelegramCapturableType("WEIGHT")).toBe(true);
expect(isTelegramCapturableType("BLOOD_GLUCOSE")).toBe(true);
});
it("rejects blood pressure (needs two values)", () => {
expect(isTelegramCapturableType("BLOOD_PRESSURE_SYS")).toBe(false);
});
it("rejects null / free-text reminders", () => {
expect(isTelegramCapturableType(null)).toBe(false);
expect(isTelegramCapturableType(undefined)).toBe(false);
});
});
describe("logTelegramMeasurement", () => {
it("writes a Measurement with source=TELEGRAM and the canonical unit", async () => {
const result = await logTelegramMeasurement({
userId: "user-1",
type: "WEIGHT",
rawText: "82,5",
tz: "Europe/Berlin",
externalId: "telegram:measure:7777:555",
});
expect(result.status).toBe("ok");
expect(measurement.create).toHaveBeenCalledTimes(1);
const data = measurement.create.mock.calls[0][0].data;
expect(data.userId).toBe("user-1");
expect(data.type).toBe("WEIGHT");
expect(data.value).toBe(82.5);
expect(data.source).toBe("TELEGRAM");
expect(data.unit).toBe("kg");
expect(data.externalId).toBe("telegram:measure:7777:555");
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("rejects a non-numeric reply without writing", async () => {
const result = await logTelegramMeasurement({
userId: "user-1",
type: "WEIGHT",
rawText: "heavy",
tz: null,
externalId: "telegram:measure:7777:556",
});
expect(result.status).toBe("invalid_number");
expect(measurement.create).not.toHaveBeenCalled();
});
it("rejects an out-of-range value without writing", async () => {
const result = await logTelegramMeasurement({
userId: "user-1",
type: "WEIGHT",
rawText: "999",
tz: null,
externalId: "telegram:measure:7777:557",
});
expect(result.status).toBe("out_of_range");
expect(measurement.create).not.toHaveBeenCalled();
});
it("refuses an unsupported (BP) type", async () => {
const result = await logTelegramMeasurement({
userId: "user-1",
type: "BLOOD_PRESSURE_SYS",
rawText: "120",
tz: null,
externalId: "telegram:measure:7777:558",
});
expect(result.status).toBe("unsupported_type");
expect(measurement.create).not.toHaveBeenCalled();
});
it("is idempotent on a redelivered reply (externalId hit)", async () => {
measurement.findUnique.mockResolvedValue({ id: "existing" });
const result = await logTelegramMeasurement({
userId: "user-1",
type: "PULSE",
rawText: "60",
tz: null,
externalId: "telegram:measure:7777:559",
});
expect(result.status).toBe("ok");
expect(measurement.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.19.2 — `User.telegramChatId` must stay UNIQUE. The inbound webhook
* resolves the user from the chat id; a chat shared across two accounts
* would route a reply / button tap / numeric capture ambiguously. The
* constraint is the structural guard, so this asserts it never silently
* drops out of the schema on a future regeneration.
*/
const SCHEMA_PATH = join(__dirname, "../../../..", "prisma", "schema.prisma");
describe("User.telegramChatId schema invariant", () => {
const schema = readFileSync(SCHEMA_PATH, "utf8");
it("declares the column @unique", () => {
const line = schema.split("\n").find((l) => l.includes("telegramChatId"));
expect(line).toBeDefined();
expect(line).toMatch(/@unique/);
});
it("ships the 0189 unique-index migration", () => {
const migrationPath = join(
__dirname,
"../../../..",
"prisma",
"migrations",
"0189_v1192_telegram_chat_unique_and_source",
"migration.sql",
);
const sql = readFileSync(migrationPath, "utf8");
expect(sql).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS/);
expect(sql).toMatch(/users_telegram_chat_id_key/);
});
});
@@ -0,0 +1,164 @@
/**
* v1.19.2 — server-authoritative measurement capture from the Telegram bot.
*
* The HTTP measurement-create path (`POST /api/measurements`) is bound to a
* cookie/Bearer session via `requireAuth`; the Telegram webhook resolves the
* user from the linked chat instead. This helper holds the shared write so
* the two entry points agree on the canonical shape: the same range guard,
* the same canonical unit, the same cache invalidation + DAY-rollup
* recompute the manual route fires. `source` is pinned to the `TELEGRAM`
* `MeasurementSource` value (added in migration 0189).
*
* The `userId` is NEVER taken from the Telegram payload — the caller passes
* the id it resolved from the stored `telegramChatId` binding. The capture
* is correlated to a specific `MeasurementReminder` (carrying the expected
* `measurementType`) through a `TelegramPromptContext` row, so a numeric
* reply can only ever land the metric that reminder asked for, for that
* user. BP (two values) is out of scope for the single-numeric-reply path.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import {
getUnitForType,
validateMeasurementRange,
} from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { getEvent } from "@/lib/logging/context";
/**
* The single-value measurement types a numeric Telegram reply can capture.
*
* Excludes BLOOD_PRESSURE_SYS — a BP reading needs systolic AND diastolic,
* which a single numeric reply can't express unambiguously. The BP reminder
* keeps its existing satisfy-only "done" flow; a value capture for it is a
* documented follow-up (a guided two-number prompt).
*/
const TELEGRAM_CAPTURABLE_TYPES: ReadonlySet<MeasurementType> =
new Set<MeasurementType>([
"WEIGHT",
"PULSE",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"BODY_FAT",
"FAT_MASS",
"FAT_FREE_MASS",
"MUSCLE_MASS",
"LEAN_BODY_MASS",
"BONE_MASS",
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
]);
/**
* True when a numeric Telegram reply can capture a Measurement for this
* reminder's target type. The webhook gates the value-prompt on this so a
* BP reminder (or a free-text Vorsorge with no `measurementType`) never
* offers an ambiguous numeric capture.
*/
export function isTelegramCapturableType(
type: MeasurementType | null | undefined,
): type is MeasurementType {
return type != null && TELEGRAM_CAPTURABLE_TYPES.has(type);
}
export interface TelegramMeasurementResult {
/** `ok` — a row was written (or already existed under the dedup key).
* `invalid_number` — the reply did not parse to a finite number.
* `out_of_range` — the value fell outside the type's plausible range.
* `unsupported_type` — the type is not single-value capturable. */
status: "ok" | "invalid_number" | "out_of_range" | "unsupported_type";
}
/**
* Parse a free-text Telegram reply into a finite number. Tolerates a
* comma decimal separator (de-DE keyboards) and surrounding whitespace;
* rejects anything with trailing non-numeric noise so "72 bpm" doesn't
* silently capture 72.
*/
export function parseTelegramNumber(text: string): number | null {
const normalised = text.trim().replace(",", ".");
if (!/^-?\d+(\.\d+)?$/.test(normalised)) return null;
const n = Number(normalised);
return Number.isFinite(n) ? n : null;
}
/**
* Capture a measurement on behalf of a Telegram-linked user from a numeric
* reply to a measurement-reminder prompt.
*
* Idempotent on `externalId` (the per-prompt key the caller derives from
* `telegram:measure:<chatId>:<promptMsgId>`): a redelivered reply converges
* onto the same row via the `(userId, type, source, externalId)` unique
* instead of minting a duplicate.
*/
export async function logTelegramMeasurement(input: {
userId: string;
type: MeasurementType;
rawText: string;
tz: string | null;
/** Stable per-prompt id for Telegram redelivery dedup. */
externalId: string;
client?: PrismaClient | Prisma.TransactionClient;
}): Promise<TelegramMeasurementResult> {
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
if (!isTelegramCapturableType(input.type)) {
return { status: "unsupported_type" };
}
const value = parseTelegramNumber(input.rawText);
if (value === null) {
return { status: "invalid_number" };
}
if (validateMeasurementRange(input.type, value) !== null) {
return { status: "out_of_range" };
}
const measuredAt = new Date();
const existing = await prisma.measurement.findUnique({
where: {
userId_type_source_externalId: {
userId: input.userId,
type: input.type,
source: "TELEGRAM",
externalId: input.externalId,
},
},
select: { id: true },
});
if (existing) {
return { status: "ok" };
}
await prisma.measurement.create({
data: {
userId: input.userId,
type: input.type,
value,
unit: getUnitForType(input.type),
source: "TELEGRAM",
measuredAt,
externalId: input.externalId,
},
});
invalidateUserMeasurements(input.userId, { evict: true });
// Best-effort rollup refresh — a cache tier, never a write-path invariant.
try {
await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt);
} catch (rollupErr) {
getEvent()?.addMeta(
"telegram_measurement_rollup_failed",
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
);
}
return { status: "ok" };
}
+7
View File
@@ -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",
]);
/**