diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f994da49e..3365e693f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4387,6 +4387,59 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/coach/reminder-suggestions: + post: + tags: + - Insights + summary: Act on a Coach cadence suggestion + description: 'v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated + (`requireModuleEnabled("coach")`); a disabled surface 403s. `accept` resolves the cadence from the closed + server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with + `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or + stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth + via cookie or Bearer; the owner is narrowed from the session.' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cadenceId: + type: string + minLength: 1 + maxLength: 64 + action: + type: string + enum: + - accept + - dismiss + - stop + required: + - cadenceId + - action + responses: + "200": + description: "A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`)." + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderSuggestionResultOk" + "201": + description: An accept that created a new reminder. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderSuggestionResultCreated" + "401": *a1 + "403": + description: Coach surface (or the cadence's module) disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/admin/invites: get: tags: @@ -15269,6 +15322,67 @@ components: - data - error additionalProperties: false + CoachReminderSuggestionResultOk: + type: object + properties: + data: + $ref: "#/components/schemas/CoachReminderSuggestionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderSuggestionResult: + type: object + properties: + ok: + type: boolean + const: true + action: + type: string + enum: + - accept + - dismiss + - stop + reminder: + description: "On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a + prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`)." + anyOf: + - {} + - type: "null" + duplicate: + description: True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op). + type: boolean + required: + - ok + - action + additionalProperties: false + description: "Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 + + `duplicate: true` when one already exists); `dismiss`/`stop` return 200." + CoachReminderSuggestionResultCreated: + type: object + properties: + data: + $ref: "#/components/schemas/CoachReminderSuggestionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false AdminInviteList: type: object properties: diff --git a/messages/de.json b/messages/de.json index d5421a396..36a22386a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6551,11 +6551,17 @@ "notifyHour": "Erinnern um", "location": "Wo (optional)", "locationPlaceholder": "z. B. Hausarzt, Labor", - "save": "Speichern" + "save": "Speichern", + "createTitle": "Neue Erinnerung", + "editTitle": "Erinnerung bearbeiten" }, "pushTitle": "Vorsorge-Erinnerung", "pushBody": "Fällig: {label}", - "pushLocation": "(bei {location})" + "pushLocation": "(bei {location})", + "edit": "Bearbeiten", + "originCoach": "Coach", + "enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren", + "until": "Bis {date}" }, "labs": { "title": "Laborwerte", diff --git a/messages/en.json b/messages/en.json index a1495e837..db889f8f1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6551,11 +6551,17 @@ "notifyHour": "Remind at", "location": "Where (optional)", "locationPlaceholder": "e.g. GP, Lab", - "save": "Save" + "save": "Save", + "createTitle": "New reminder", + "editTitle": "Edit reminder" }, "pushTitle": "Preventive-care reminder", "pushBody": "Time for: {label}", - "pushLocation": "(at {location})" + "pushLocation": "(at {location})", + "edit": "Edit", + "originCoach": "Coach", + "enabledToggleAria": "Enable or disable this reminder", + "until": "Until {date}" }, "labs": { "title": "Lab results", diff --git a/messages/es.json b/messages/es.json index 6c88b63ae..f7ea041a9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6551,11 +6551,17 @@ "notifyHour": "Recordar a las", "location": "Dónde (opcional)", "locationPlaceholder": "p. ej. Médico, Laboratorio", - "save": "Guardar" + "save": "Guardar", + "createTitle": "Nuevo recordatorio", + "editTitle": "Editar recordatorio" }, "pushTitle": "Recordatorio de prevención", "pushBody": "Toca: {label}", - "pushLocation": "(en {location})" + "pushLocation": "(en {location})", + "edit": "Editar", + "originCoach": "Coach", + "enabledToggleAria": "Activar o desactivar este recordatorio", + "until": "Hasta {date}" }, "labs": { "title": "Resultados de análisis", diff --git a/messages/fr.json b/messages/fr.json index 079a2b06f..1e513e800 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6551,11 +6551,17 @@ "notifyHour": "Rappeler à", "location": "Où (facultatif)", "locationPlaceholder": "ex. Médecin, Laboratoire", - "save": "Enregistrer" + "save": "Enregistrer", + "createTitle": "Nouveau rappel", + "editTitle": "Modifier le rappel" }, "pushTitle": "Rappel de prévention", "pushBody": "C'est l'heure : {label}", - "pushLocation": "(chez {location})" + "pushLocation": "(chez {location})", + "edit": "Modifier", + "originCoach": "Coach", + "enabledToggleAria": "Activer ou désactiver ce rappel", + "until": "Jusqu'au {date}" }, "labs": { "title": "Résultats d'analyses", diff --git a/messages/it.json b/messages/it.json index fcea894ef..c220d6281 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6551,11 +6551,17 @@ "notifyHour": "Ricorda alle", "location": "Dove (facoltativo)", "locationPlaceholder": "es. Medico, Laboratorio", - "save": "Salva" + "save": "Salva", + "createTitle": "Nuovo promemoria", + "editTitle": "Modifica promemoria" }, "pushTitle": "Promemoria di prevenzione", "pushBody": "È ora di: {label}", - "pushLocation": "(presso {location})" + "pushLocation": "(presso {location})", + "edit": "Modifica", + "originCoach": "Coach", + "enabledToggleAria": "Attiva o disattiva questo promemoria", + "until": "Fino al {date}" }, "labs": { "title": "Risultati degli esami", diff --git a/messages/pl.json b/messages/pl.json index 1c7b3ed76..7e9c2c9aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6551,11 +6551,17 @@ "notifyHour": "Przypomnij o", "location": "Gdzie (opcjonalnie)", "locationPlaceholder": "np. Lekarz, Laboratorium", - "save": "Zapisz" + "save": "Zapisz", + "createTitle": "Nowe przypomnienie", + "editTitle": "Edytuj przypomnienie" }, "pushTitle": "Przypomnienie profilaktyczne", "pushBody": "Czas na: {label}", - "pushLocation": "(u {location})" + "pushLocation": "(u {location})", + "edit": "Edytuj", + "originCoach": "Coach", + "enabledToggleAria": "Włącz lub wyłącz to przypomnienie", + "until": "Do {date}" }, "labs": { "title": "Wyniki badań", diff --git a/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql index e7ebc3553..cafe77df5 100644 --- a/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql +++ b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql @@ -1,4 +1,4 @@ --- v1.19.0 — user-scoped biomarker catalog. +-- v1.18.1 — user-scoped biomarker catalog. -- -- The lean v1.17.1 `lab_results` row stores analyte / unit / reference_low / -- reference_high on EVERY row, so recording the same marker three times means diff --git a/prisma/migrations/0171_v1181_illness_episode/migration.sql b/prisma/migrations/0171_v1181_illness_episode/migration.sql index 60ec415ee..b5aa4577f 100644 --- a/prisma/migrations/0171_v1181_illness_episode/migration.sql +++ b/prisma/migrations/0171_v1181_illness_episode/migration.sql @@ -1,4 +1,4 @@ --- v1.19.0 — illness / condition journal. +-- v1.18.1 — illness / condition journal. -- -- A CONDITION journal (not a flu log): any illness/condition kept for -- oneself, with a retrospective lens. Retrospective + correlated, NEVER a diff --git a/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql b/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql new file mode 100644 index 000000000..e978b7cf4 --- /dev/null +++ b/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql @@ -0,0 +1,32 @@ +-- v1.18.1 — structural accept-dedup for COACH-minted measurement reminders. +-- +-- The Coach can mint a `MeasurementReminder` (origin: COACH) from an +-- evidence-based cadence suggestion. The accept endpoint already does a +-- read-then-create dedup, but a read-then-create is racy: two near-simultaneous +-- accepts (a re-tap, or a stale card replayed) can both pass the existence +-- check and create twins. Close the race at the database with a PARTIAL unique +-- index: at most one LIVE COACH reminder per (user, metric). VORSORGE rows are +-- unaffected, and a soft-deleted (deleted_at IS NOT NULL) COACH row never +-- blocks a fresh accept of the same cadence. +-- +-- Defensive pre-clean: should any duplicate live COACH rows already exist +-- (created before this constraint landed), keep the most recently created one +-- and tombstone the rest so the unique index can be built without error. +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, measurement_type + ORDER BY created_at DESC, id DESC + ) AS rn + FROM "measurement_reminders" + WHERE origin = 'COACH' AND deleted_at IS NULL +) +UPDATE "measurement_reminders" m +SET deleted_at = NOW() +FROM ranked +WHERE m.id = ranked.id AND ranked.rn > 1; + +CREATE UNIQUE INDEX "measurement_reminders_coach_metric_live_key" + ON "measurement_reminders" ("user_id", "measurement_type") + WHERE origin = 'COACH' AND deleted_at IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83e335d28..a69270678 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3298,6 +3298,11 @@ model MeasurementReminder { @@index([userId, deletedAt]) @@index([userId, nextDueAt]) + // v1.18.1 — structural accept-dedup for COACH-minted reminders: at most + // one LIVE COACH reminder per (user, metric). Enforced as a PARTIAL unique + // index (`WHERE origin = 'COACH' AND deleted_at IS NULL`) created by the + // 0172 migration — Prisma's schema DSL cannot express a filtered unique + // index, so the migration owns the constraint and this is a pointer to it. @@map("measurement_reminders") } diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts index d8647d27d..170130bf5 100644 --- a/src/app/api/coach/reminder-suggestions/route.ts +++ b/src/app/api/coach/reminder-suggestions/route.ts @@ -19,8 +19,10 @@ */ import { NextRequest } from "next/server"; +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -55,6 +57,18 @@ async function postAction(request: NextRequest): Promise { const gate = await requireModuleEnabled(user.id, "coach"); if (!gate.enabled) return gate.response; + // Per-user rate limit — the action card is one-tap, so a tight per-user + // bucket (userId, never IP) is the right granularity. Mirrors the + // labs/restore convention; a trust-violation can never widen it. + const rate = await checkRateLimit( + `coach:reminder-suggestion:${user.id}`, + 30, + 60_000, + ); + if (!rate.allowed) { + return apiError("coach.suggestion.rateLimited", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 4 * 1024, }); @@ -150,22 +164,44 @@ async function postAction(request: NextRequest): Promise { // Field-by-field — no mass assignment. `origin: COACH` is the // provenance the UI labels and the SUGGEST gate dedupes against. - const created = await prisma.measurementReminder.create({ - data: { - userId: user.id, - label: cadence.labelKey, - measurementType: cadence.measurementType, - intervalDays: cadence.intervalDays, - rrule: cadence.rrule, - anchorDate: null, - endsOn, - origin: "COACH", - notifyHour: cadence.notifyHour, - location: null, - enabled: true, - nextDueAt, - }, - }); + let created; + try { + created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: cadence.labelKey, + measurementType: cadence.measurementType, + intervalDays: cadence.intervalDays, + rrule: cadence.rrule, + anchorDate: null, + endsOn, + origin: "COACH", + notifyHour: cadence.notifyHour, + location: null, + enabled: true, + nextDueAt, + }, + }); + } catch (err) { + // Structural dedup backstop: the 0172 partial unique index + // (`origin = 'COACH' AND deleted_at IS NULL` per user+metric) wins the + // race the read-then-create above can lose. Treat the violation as the + // same idempotent "already have one" outcome. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + annotate({ + action: { name: "coach.reminder.accept.duplicate" }, + meta: { cadenceId, reason: "unique-violation" }, + }); + return apiSuccess( + { ok: true, action: "accept", reminder: null, duplicate: true }, + 200, + ); + } + throw err; + } await auditLog("measurementReminder.create.coach", { userId: user.id, diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx index e123b9828..126f17539 100644 --- a/src/app/vorsorge/page.tsx +++ b/src/app/vorsorge/page.tsx @@ -1,19 +1,38 @@ "use client"; +import { useCallback } from "react"; import { Loader2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; +import { queryKeys } from "@/lib/query-keys"; /** * v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated * feature surface for "wann muss ich was wo machen". Auth-gated; the * section component owns the list + create flow. + * + * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, + * measurements): a pull invalidates the measurement-reminders read so the + * list + server-computed next-due repaint. */ export default function VorsorgePage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); + const queryClient = useQueryClient(); + + const refresh = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: queryKeys.measurementReminders(), + }), + [queryClient], + ); + const pull = usePullToRefresh({ onRefresh: refresh }); if (!mounted || isLoading) { return ( @@ -25,6 +44,7 @@ export default function VorsorgePage() { return (
+
); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index abbeb801f..5b229441f 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -363,6 +363,10 @@ export function CoachConversation({ surface === "page" ? ( ` band already renders the + // "Conversations"

; suppress the rail's own

so the + // heading is not stacked twice on the page surface. + hideHeading onSelect={(id) => { // v1.16.5 — switching conversations drops the guided // session; unanswered questions stay pending. diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index a4fc9a33a..f59461b02 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -97,7 +97,11 @@ export function CoachDrawerBody({ two-height split). */}
` (px-4) all share the same horizontal divider + // line at sm+ rather than stepping in/out by a pixel. + className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4" > + + + } + > + {/* v1.18.1 — the cadence/type/hour pickers use NativeSelect (not the + Radix Select labs/illness use) deliberately: these are long, + grouped, scalar option lists (24 hours, a 15-type grouped metric + list) where the OS-native picker is faster on touch and avoids a + tall scrolling Radix popover inside the bottom-sheet. The form + fields below stay NativeSelect by design, not drift. */} +
+
+ + + setForm((f) => ({ ...f, label: e.target.value })) + } + /> +
+
+ + + setForm((f) => ({ + ...f, + kind: e.target.value as "type" | "freeText", + })) + } + > + + + +
+ + {form.kind === "type" && (
-
+ )} - {kind === "type" && ( -
- - setMeasurementType(e.target.value)} - > - {TYPE_GROUPS.map(({ group, types }) => ( - - {types.map((type) => ( - - ))} - - ))} - -
- )} - -
-
- - setIntervalDays(Number(e.target.value))} - > - {INTERVAL_PRESETS.map((days) => ( - - ))} - -
-
- - setNotifyHour(Number(e.target.value))} - > - {Array.from({ length: 24 }, (_, h) => ( - - ))} - -
-
- +
-
- -
- - + {INTERVAL_PRESETS.map((days) => ( + + ))} +
- - - )} +
+ + + setForm((f) => ({ ...f, notifyHour: Number(e.target.value) })) + } + > + {Array.from({ length: 24 }, (_, h) => ( + + ))} + +
+
- {isLoading && !showForm && ( +
+ + + setForm((f) => ({ ...f, location: e.target.value })) + } + /> +
+
+ + + {isLoading && (
{Array.from({ length: 3 }, (_, i) => (
)} - {!isLoading && (reminders?.length ?? 0) === 0 && !showForm && ( + {!isLoading && (reminders?.length ?? 0) === 0 && ( } title={t("measurementReminders.empty.title")} description={t("measurementReminders.empty.description")} action={ - } @@ -369,7 +448,11 @@ export function VorsorgeSection({ now={now} onSatisfy={() => satisfy.mutate(reminder.id)} onRemove={() => remove.mutate(reminder.id)} - busy={satisfy.isPending || remove.isPending} + onEdit={() => openEdit(reminder)} + onToggleEnabled={(next) => + update.mutate({ id: reminder.id, body: { enabled: next } }) + } + busy={satisfy.isPending || remove.isPending || update.isPending} /> ))} @@ -382,15 +465,20 @@ function VorsorgeCard({ now, onSatisfy, onRemove, + onEdit, + onToggleEnabled, busy, }: { reminder: MeasurementReminder; now: number; onSatisfy: () => void; onRemove: () => void; + onEdit: () => void; + onToggleEnabled: (next: boolean) => void; busy: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const due = relativeDueKey(reminder.nextDueAt, now); const cadence = reminder.intervalDays != null @@ -400,6 +488,7 @@ function VorsorgeCard({ : reminder.rrule ? t("measurementReminders.cadence.custom") : ""; + const isCoach = reminder.origin === "COACH"; return (
  • @@ -407,15 +496,29 @@ function VorsorgeCard({ through the discreet badge below. */} - - {reminder.label} + + + {resolveReminderLabel(reminder, t)} + + {isCoach && ( + // Neutral provenance badge — no status colour per the rule. + + {t("measurementReminders.originCoach")} + + )} {!reminder.enabled && ( {t("measurementReminders.disabledBadge")} )} - + + + {cadence}

    + {reminder.endsOn && ( +

    + {t("measurementReminders.until", { + date: fmt.date(new Date(reminder.endsOn)), + })} +

    + )} {reminder.location && (

    {t("measurementReminders.location.prefix", { diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index 243efe90f..23921c0ca 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -21,6 +21,18 @@ export interface MeasurementReminder { intervalDays: number | null; rrule: string | null; anchorDate: string | null; + /** + * v1.18.1 — optional course-window end (ISO-8601). NULL ⇒ open-ended; a + * Coach-suggested time-boxed protocol carries a non-NULL value and the + * UI renders an "until " line. + */ + endsOn: string | null; + /** + * v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from + * a Coach cadence suggestion). The UI labels the source and translates a + * COACH row's `label` (which carries an i18n key, not free text). + */ + origin: "VORSORGE" | "COACH"; notifyHour: number; location: string | null; nextDueAt: string | null; diff --git a/src/lib/ai/coach/__tests__/suggest-reminder.test.ts b/src/lib/ai/coach/__tests__/suggest-reminder.test.ts index d8d3d173e..51396d578 100644 --- a/src/lib/ai/coach/__tests__/suggest-reminder.test.ts +++ b/src/lib/ai/coach/__tests__/suggest-reminder.test.ts @@ -26,6 +26,28 @@ describe("parseSuggestReminder", () => { expect(r.malformed).toBe(false); }); + it("keeps prose written AFTER the block (text-before + text-after)", () => { + const raw = + "Before the block.\n" + + "---SUGGEST-REMINDER---\ncadence: bp_7_2_2\n---END---\n" + + "And a closing thought after it."; + const r = parseSuggestReminder(raw); + expect(r.cadence?.id).toBe("bp_7_2_2"); + expect(r.prose).toContain("Before the block."); + expect(r.prose).toContain("And a closing thought after it."); + expect(r.prose).not.toContain("SUGGEST-REMINDER"); + }); + + it("keeps trailing prose even on a malformed block", () => { + const raw = + "Before.\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---\nAfter."; + const r = parseSuggestReminder(raw); + expect(r.malformed).toBe(true); + expect(r.prose).toContain("Before."); + expect(r.prose).toContain("After."); + expect(r.prose).not.toContain("SUGGEST-REMINDER"); + }); + it("strips quotes/backticks the model may add around the id", () => { const raw = "text\n---SUGGEST-REMINDER---\ncadence: `weight_daily`\n---END---"; diff --git a/src/lib/ai/coach/suggest-reminder.ts b/src/lib/ai/coach/suggest-reminder.ts index 4eb4df5bc..a4844e381 100644 --- a/src/lib/ai/coach/suggest-reminder.ts +++ b/src/lib/ai/coach/suggest-reminder.ts @@ -166,7 +166,7 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult { return { prose: raw, cadence: null, malformed: false }; } - const prose = raw.slice(0, openIdx).replace(/\s+$/u, ""); + const before = raw.slice(0, openIdx); const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length); const closeRel = afterOpen.indexOf(CLOSE_SENTINEL); // Cap the payload before parsing regardless of whether the close marker @@ -178,6 +178,16 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult { ? bodyRaw.slice(0, SENTINEL_BYTE_CAP) : bodyRaw; + // Reconstruct the prose as text-before-open + text-after-close so a model + // that writes a sentence AFTER the block (or after a malformed one) keeps + // it. Only the sentinel block itself is stripped; when the close marker is + // missing there is no trailing text to recover. + const after = + closeRel === -1 + ? "" + : afterOpen.slice(closeRel + CLOSE_SENTINEL.length); + const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, ""); + const cadenceId = extractCadenceId(body); if (cadenceId && isCadenceId(cadenceId)) { return { prose, cadence: CADENCE_CATALOG[cadenceId], malformed: false }; diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 49dff77d3..1e73f40c3 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -146,6 +146,27 @@ function makePrisma(opts: { return { id: where.id, ...data }; }, ), + // v1.18.1 — two callers use `updateMany` against this model now: + // - `satisfyReminder` (conditional forward-only write) — has `where.id` + // and should record the write so the satisfy assertions still see it. + // - the tick's expired-COACH cleanup sweep — no `where.id`; record + // nothing and report zero rows cleaned (the fixtures carry no + // expired COACH rows). + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: { id?: string }; + data: Record; + }) => { + if (where.id) { + updates.push({ id: where.id, data }); + return { count: 1 }; + } + return { count: 0 }; + }, + ), }, measurement: { findFirst: vi.fn(async () => opts.measurementMatch ?? null), diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index 5320c618b..3754f95c5 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -57,6 +57,14 @@ function makePrisma(opts: { return { id: where.id, ...data }; }, ), + // v1.18.1 — `satisfyReminder` now writes via a conditional `updateMany` + // (forward-only TOCTOU close). Record the write + report a hit. + updateMany: vi.fn( + async ({ where, data }: { where: { id: string }; data: Record }) => { + updates.push({ id: where.id, data }); + return { count: 1 }; + }, + ), }, measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) }, labResult: { findFirst: vi.fn(async () => opts.lab ?? null) }, diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 0d960d838..a6567a779 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -86,9 +86,37 @@ export interface MeasurementReminderSummary { skippedModuleDisabled: number; skippedClientManaged: number; skippedNoChannel: number; + /** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */ + expiredCleaned: number; failed: number; } +/** + * v1.18.1 — soft-delete COACH course-window reminders whose finite window + * has elapsed. A Coach-suggested protocol (the ESH/AHA 7-day BP cadence) + * carries a non-NULL `endsOn`; once the recurrence engine walks past it the + * row's `nextDueAt` is stamped NULL (no future occurrence). Such a row can + * never fire again, so it would otherwise linger forever in the Vorsorge + * list as a dead "completed course". Tombstone it so the surface stays + * clean. Scoped to `origin: COACH` so a user's open-ended VORSORGE row with + * a one-shot `endsOn` is never touched without intent. + */ +async function cleanupExpiredCoachReminders( + prisma: PrismaClient, + now: Date, +): Promise { + const result = await prisma.measurementReminder.updateMany({ + where: { + deletedAt: null, + origin: "COACH", + endsOn: { not: null, lt: now }, + nextDueAt: null, + }, + data: { deletedAt: now }, + }); + return result.count; +} + function resolveLocale(locale: string | null | undefined): Locale { return locales.includes(locale as Locale) ? (locale as Locale) @@ -171,9 +199,15 @@ export async function runMeasurementReminderTick( skippedModuleDisabled: 0, skippedClientManaged: 0, skippedNoChannel: 0, + expiredCleaned: 0, failed: 0, }; + // v1.18.1 — sweep expired COACH course-window reminders before scanning + // the due set, so a self-expired protocol drops out of the list and the + // dispatch loop never re-evaluates a row that can never fire again. + summary.expiredCleaned = await cleanupExpiredCoachReminders(prisma, now); + // Bound the scan to reminders that could plausibly fire this tick so Postgres // uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the // whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts index 8fdcec82f..8658b00c0 100644 --- a/src/lib/measurement-reminders/__tests__/satisfy.test.ts +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -10,20 +10,27 @@ import { satisfyReminder } from "../satisfy"; const TZ = "Europe/Berlin"; -function makePrisma() { - const updates: Array<{ id: string; data: Record }> = []; +function makePrisma(updateManyCount = 1) { + const updates: Array<{ + where: Record; + data: Record; + }> = []; const prisma = { measurementReminder: { - update: vi.fn( + // v1.18.1 — `satisfyReminder` now writes via a conditional + // `updateMany` so the forward-only guard re-asserts at the DB row + // (close the cron-vs-worker TOCTOU). `updateManyCount` simulates a + // racing writer having already advanced the row (count === 0). + updateMany: vi.fn( async ({ where, data, }: { - where: { id: string }; + where: Record; data: Record; }) => { - updates.push({ id: where.id, data }); - return { id: where.id, ...data }; + if (updateManyCount > 0) updates.push({ where, data }); + return { count: updateManyCount }; }, ), }, @@ -66,6 +73,35 @@ describe("satisfyReminder", () => { ); }); + it("re-asserts the forward-only invariant in the conditional updateMany", async () => { + const { prisma } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + await satisfyReminder(prisma as never, reminder(), TZ, at); + + const call = prisma.measurementReminder.updateMany.mock + .calls[0][0] as unknown as { + where: { id: string; OR: unknown[] }; + }; + expect(call.where.id).toBe("r1"); + expect(call.where.OR).toEqual([ + { lastSatisfiedAt: null }, + { lastSatisfiedAt: { lt: at } }, + ]); + }); + + it("treats a racing-writer updateMany count of 0 as a forward-only no-op", async () => { + const { prisma } = makePrisma(0); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await satisfyReminder(prisma as never, reminder(), TZ, at); + + // The in-memory guard passed (lastSatisfiedAt null) but the DB write + // matched no row — a concurrent satisfy already advanced it. No-op. + expect(result.satisfied).toBe(false); + expect(result.nextDueAt).toBeNull(); + }); + it("advances when the event is strictly after the existing lastSatisfiedAt", async () => { const { prisma, updates } = makePrisma(); const prev = new Date("2026-06-10T08:00:00Z"); @@ -96,7 +132,7 @@ describe("satisfyReminder", () => { expect(result.satisfied).toBe(false); expect(result.nextDueAt).toBeNull(); - expect(prisma.measurementReminder.update).not.toHaveBeenCalled(); + expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled(); expect(updates).toHaveLength(0); }); @@ -112,6 +148,6 @@ describe("satisfyReminder", () => { ); expect(result.satisfied).toBe(false); - expect(prisma.measurementReminder.update).not.toHaveBeenCalled(); + expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts index e49caa45b..5cdec09eb 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from "vitest"; import { + byHourTimesOfDay, computeReminderNextDueAt, type ReminderScheduleInput, } from "../scheduling"; @@ -97,4 +98,51 @@ describe("computeReminderNextDueAt", () => { expect(due!.getUTCFullYear()).toBe(2027); expect(due!.getUTCMonth()).toBe(5); // June (0-indexed) }); + + // v1.18.1 — the twice-daily BP protocol (BYHOUR=7,19) must fire at BOTH + // clock hours, not collapse to the single notifyHour. + it("rrule BYHOUR=7,19: next due lands on the earliest unfired clock hour", () => { + const reminder = base({ + rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1", + anchorDate: new Date("2026-06-10T00:00:00Z"), + createdAt: new Date("2026-06-10T00:00:00Z"), + notifyHour: 7, + }); + // After 06:00 Berlin (04:00Z) on the anchor day → the 07:00 slot. + const morning = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-10T04:00:00Z"), + ); + // 07:00 Berlin == 05:00Z (DST). + expect(morning!.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + + // After the morning slot but before evening → the 19:00 slot SAME day. + const evening = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-10T06:00:00Z"), + ); + // 19:00 Berlin == 17:00Z (DST). + expect(evening!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); + }); +}); + +describe("byHourTimesOfDay", () => { + it("parses BYHOUR into sorted, deduped HH:00 strings", () => { + expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=19,7,7;INTERVAL=1")).toEqual([ + "07:00", + "19:00", + ]); + }); + + it("returns null for an rrule without BYHOUR", () => { + expect(byHourTimesOfDay("FREQ=YEARLY")).toBeNull(); + expect(byHourTimesOfDay(null)).toBeNull(); + }); + + it("drops out-of-range hours and returns null when none survive", () => { + expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=24,99")).toBeNull(); + expect(byHourTimesOfDay("FREQ=DAILY;BYHOUR=6,30")).toEqual(["06:00"]); + }); }); diff --git a/src/lib/measurement-reminders/satisfy.ts b/src/lib/measurement-reminders/satisfy.ts index d0fd8ce43..fa40095a1 100644 --- a/src/lib/measurement-reminders/satisfy.ts +++ b/src/lib/measurement-reminders/satisfy.ts @@ -92,10 +92,26 @@ export async function satisfyReminder( satisfiedAt, ); - await prisma.measurementReminder.update({ - where: { id: reminder.id }, + // v1.18.1 — close the forward-only TOCTOU: the in-memory guard above can + // pass concurrently in the cron poll AND the eventful worker for the same + // reading. Make the write itself conditional so exactly one wins. The + // `updateMany` filter re-asserts the forward-only invariant against the + // CURRENT row state; a racing writer that already advanced `lastSatisfiedAt` + // to >= satisfiedAt yields `count === 0`, which we treat as a no-op rather + // than re-stamping an older instant. + const result = await prisma.measurementReminder.updateMany({ + where: { + id: reminder.id, + OR: [ + { lastSatisfiedAt: null }, + { lastSatisfiedAt: { lt: satisfiedAt } }, + ], + }, data: { lastSatisfiedAt: satisfiedAt, nextDueAt }, }); + if (result.count === 0) { + return { satisfied: false, nextDueAt: null }; + } return { satisfied: true, nextDueAt }; } diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 67415e15c..abb58ba5d 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -55,6 +55,34 @@ function hourToHhmm(hour: number): string { return `${safe.toString().padStart(2, "0")}:00`; } +/** + * v1.18.1 — pull the explicit clock hours out of an RRULE `BYHOUR` part so + * a multi-time-of-day protocol (the ESH/AHA "BP morning + evening" cadence, + * `FREQ=DAILY;BYHOUR=7,19`) fires once PER hour, not once a day. + * + * The recurrence engine expands `timesOfDay` on each occurrence day. The + * RRULE day-anchor walk on its own lands one slot per day; without lifting + * `BYHOUR` into `timesOfDay` the engine only ever fires at the single + * `notifyHour`, silently collapsing a twice-daily protocol to once. This + * keeps the label, the RRULE, and the engine output aligned. + * + * Returns the sorted, de-duplicated, in-range (0–23) `"HH:00"` strings, or + * `null` when the rrule carries no usable `BYHOUR` (the caller then falls + * back to the single `notifyHour`). + */ +export function byHourTimesOfDay(rrule: string | null): string[] | null { + if (!rrule) return null; + const match = /(?:^|;)BYHOUR=([0-9,]+)(?:;|$)/i.exec(rrule); + if (!match) return null; + const hours = match[1] + .split(",") + .map((part) => Number(part.trim())) + .filter((h) => Number.isInteger(h) && h >= 0 && h <= 23); + if (hours.length === 0) return null; + const unique = Array.from(new Set(hours)).sort((a, b) => a - b); + return unique.map((h) => `${h.toString().padStart(2, "0")}:00`); +} + /** * Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine * consumes for one Vorsorge reminder. @@ -69,12 +97,17 @@ export function buildReminderRecurrence( timeZone: string, ): { schedule: CanonicalSchedule; ctx: RecurrenceContext } { const hhmm = hourToHhmm(reminder.notifyHour); + // v1.18.1 — when the RRULE pins explicit BYHOUR clock hours (e.g. the + // twice-daily BP protocol `FREQ=DAILY;BYHOUR=7,19`), expand them into the + // engine's per-day timesOfDay so every hour fires; otherwise the cadence + // rides the single notifyHour. Aligns label + RRULE + engine output. + const timesOfDay = byHourTimesOfDay(reminder.rrule) ?? [hhmm]; const schedule: CanonicalSchedule = { id: "measurement-reminder", rrule: reminder.rrule, rollingIntervalDays: reminder.intervalDays, - timesOfDay: [hhmm], + timesOfDay, daysOfWeek: null, windowStart: hhmm, windowEnd: hhmm, diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 92e5653e3..94482c51d 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -16,8 +16,94 @@ import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +// ── Coach cadence suggestions (v1.18.1) ────────────────────────────── +// The action endpoint behind the one-tap reminder-suggestion card. The +// client sends ONLY the cadence id + the action; the server resolves the +// metric + schedule + course window from the closed catalog and (for +// `accept`) mints a `MeasurementReminder` with `origin: COACH`. +coachReminderSuggestionActionSchema.meta({ + id: "CoachReminderSuggestionAction", + description: + "v1.18.1 — act on a Coach cadence suggestion. `cadenceId` names a closed-catalog preset (e.g. `weight_daily`, `bp_7_2_2`); the client never sends a schedule. `action`: `accept` creates a `MeasurementReminder` (origin: COACH) through the same engine the Vorsorge surface uses; `dismiss` records dismissal memory; `stop` suppresses all future cadence suggestions. Strict: unknown keys 422.", +}); + +const coachReminderSuggestionResultSchema = z + .object({ + ok: z.literal(true), + action: z.enum(["accept", "dismiss", "stop"]), + reminder: z + .unknown() + .nullable() + .optional() + .describe( + "On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`).", + ), + duplicate: z + .boolean() + .optional() + .describe( + "True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op).", + ), + }) + .meta({ + id: "CoachReminderSuggestionResult", + description: + "Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 + `duplicate: true` when one already exists); `dismiss`/`stop` return 200.", + }); + +export const coachReminderSuggestionPaths: NonNullable< + ZodOpenApiObject["paths"] +> = { + "/api/coach/reminder-suggestions": { + post: { + tags: ["Insights"], + summary: "Act on a Coach cadence suggestion", + description: + "v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated (`requireModuleEnabled(\"coach\")`); a disabled surface 403s. `accept` resolves the cadence from the closed server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth via cookie or Bearer; the owner is narrowed from the session.", + requestBody: { + required: true, + content: { + "application/json": { schema: coachReminderSuggestionActionSchema }, + }, + }, + responses: { + "200": { + description: + "A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`).", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderSuggestionResultSchema, + "CoachReminderSuggestionResultOk", + ), + }, + }, + }, + "201": { + description: "An accept that created a new reminder.", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderSuggestionResultSchema, + "CoachReminderSuggestionResultCreated", + ), + }, + }, + }, + "403": { + description: "Coach surface (or the cadence's module) disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + // 401 / 422 / 429 come from stdResponses. + ...stdResponses, + }, + }, + }, +}; + const coachMessageFeedbackBody = z .object({ rating: z.enum(["helpful", "unhelpful"]), diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 7799b7c5d..dbab3937c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -23,7 +23,11 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { authPaths } from "./auth"; -import { coachFeedbackPaths, coachPaths } from "./coach"; +import { + coachFeedbackPaths, + coachPaths, + coachReminderSuggestionPaths, +} from "./coach"; import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; @@ -55,6 +59,7 @@ export const openApiPaths: NonNullable = { ...medicationPaths, ...profilePaths, ...coachPaths, + ...coachReminderSuggestionPaths, ...adminInvitePaths, ...coachFeedbackPaths, ...adminDiagnosticPaths,