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

@@ -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")
}