From 270f33edb2f9a679189628d4f140115f2ceff0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 14:26:44 +0200 Subject: [PATCH] feat(daily): let the Today rail dismiss milestone / ECG / tension cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worth-a-look rail mixed actionable items (an overdue dose, a broken sync) with observational ones (a milestone, a new ECG pointer, a tension window) that have nothing to act on beyond "seen it" — there was no way to clear them short of them expiring on their own. Add server-side dismissal scoped to the three observational kinds only: a new DismissedPriorityItem table (userId + itemKey, composite-unique), a POST /api/daily/digest/dismiss route, and a deterministic itemKey the digest builder stamps per kind (metric + reach day for a milestone, the recording's own timestamp for an ECG, the local day + part of day for a tension window) so a dismissal never bleeds into a later day's instance. The actionable kinds never carry an itemKey, so the dismiss affordance and the endpoint are both structurally unreachable for them — an itemKey not namespaced under a dismissible kind 422s before any lookup. --- docs/api/openapi.yaml | 64 +++++++ .../migration.sql | 30 ++++ prisma/schema.prisma | 30 ++++ .../digest/dismiss/__tests__/route.test.ts | 164 ++++++++++++++++++ src/app/api/daily/digest/dismiss/route.ts | 82 +++++++++ .../daily/__tests__/priority-card.test.tsx | 57 ++++++ src/components/daily/priority-card.tsx | 56 +++++- src/components/daily/today-hero.tsx | 3 + src/hooks/use-priority-item-dismiss.ts | 29 ++++ src/lib/daily/__tests__/digest.test.ts | 95 ++++++++++ .../daily/__tests__/priority-item-key.test.ts | 79 +++++++++ src/lib/daily/digest.ts | 42 ++++- src/lib/daily/load-digest.ts | 28 +++ src/lib/daily/priority-item-key.ts | 41 +++++ src/lib/daily/priority-item.ts | 46 +++++ src/lib/openapi/routes/daily.ts | 36 ++++ src/lib/validations/daily.ts | 30 ++++ 17 files changed, 903 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0248_dismissed_priority_items/migration.sql create mode 100644 src/app/api/daily/digest/dismiss/__tests__/route.test.ts create mode 100644 src/app/api/daily/digest/dismiss/route.ts create mode 100644 src/hooks/use-priority-item-dismiss.ts create mode 100644 src/lib/daily/__tests__/priority-item-key.test.ts create mode 100644 src/lib/daily/priority-item-key.ts create mode 100644 src/lib/validations/daily.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b0b02a91..0a6c2842d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10848,6 +10848,42 @@ paths: "403": *a5 "422": *a3 "429": *a4 + /api/daily/digest/dismiss: + post: + tags: + - Insights + summary: Dismiss a Today rail item + description: "\"Dismiss / mark seen\" for the Today rail's OBSERVATIONAL PriorityItem kinds only — milestone, + ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, + coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds + or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second + device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or + Bearer auth." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + itemKey: + type: string + minLength: 1 + maxLength: 200 + required: + - itemKey + additionalProperties: false + responses: + "200": + description: The item is dismissed (idempotent). + content: + application/json: + schema: + $ref: "#/components/schemas/DismissPriorityItemEnvelope" + "401": *a1 + "403": *a5 + "422": *a3 + "429": *a4 components: schemas: EncryptedExportRequest: @@ -30150,6 +30186,10 @@ components: - ecg_new_recording - tension_window description: Closed rail-item kind. + itemKey: + description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / + ecg_new_recording / tension_window) — absent on the actionable kinds, which are never dismissible. + type: string title: type: string description: Localised headline (resolved server-side). @@ -30193,6 +30233,30 @@ components: - actions additionalProperties: false description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard. + DismissPriorityItemEnvelope: + type: object + properties: + data: + type: object + properties: + dismissed: + type: boolean + const: true + required: + - dismissed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/prisma/migrations/0248_dismissed_priority_items/migration.sql b/prisma/migrations/0248_dismissed_priority_items/migration.sql new file mode 100644 index 000000000..fb6b95de8 --- /dev/null +++ b/prisma/migrations/0248_dismissed_priority_items/migration.sql @@ -0,0 +1,30 @@ +-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL +-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The +-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) +-- never write a row here — they clear on their own once the user acts. +-- +-- `item_key` is a deterministic identity for the candidate INSTANCE the +-- digest builder already computes (kind + metric + reach day for a +-- milestone, the recording's own timestamp for an ECG, the local day for a +-- tension window) — not the bare kind — so dismissing today's milestone never +-- suppresses tomorrow's. + +-- CreateTable +CREATE TABLE "dismissed_priority_items" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "item_key" TEXT NOT NULL, + "dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex — the dismiss upsert's natural key + the digest read's +-- `userId + itemKey IN (...)` lookup. +CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key" + ON "dismissed_priority_items" ("user_id", "item_key"); + +-- AddForeignKey — deleting a user cascades its dismissal rows away. +ALTER TABLE "dismissed_priority_items" + ADD CONSTRAINT "dismissed_priority_items_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba24b0d8e..3dafaa7b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -719,6 +719,8 @@ model User { // ships with v1.4.25 so the iOS app can build its query path // against a stable contract. personalRecords PersonalRecord[] + // Today rail dismiss ledger — observational PriorityItem kinds only. + dismissedPriorityItems DismissedPriorityItem[] // v1.4.25 W19b — per-pen / per-vial inventory entities, the // companion to the running-sum MedicationInventoryEvent ledger. // Carries the EMA §6.3 30-day in-use clock per opened pen. @@ -2100,6 +2102,34 @@ model PersonalRecord { @@map("personal_records") } +/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL +/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`, +/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue, +/// preventive_care, coach_checkin) never write a row here — they clear on +/// their own once the user acts, so a dismiss affordance on them would just +/// duplicate the existing resolution path. +/// +/// `itemKey` is a deterministic identity for the candidate INSTANCE the +/// digest builder already computes (kind + metric + reach day for a +/// milestone, the recording's own timestamp for an ECG, the local day for a +/// tension window) — not the bare `kind` — so dismissing today's milestone +/// never suppresses tomorrow's; the underlying item's own freshness window +/// (reached-once / 24h / one-per-day) is what actually retires the row's +/// relevance, this table only fast-forwards that for the rest of today. +model DismissedPriorityItem { + id String @id @default(cuid()) + userId String @map("user_id") + itemKey String @map("item_key") + dismissedAt DateTime @default(now()) @map("dismissed_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// The dismiss upsert's natural key + the digest read's + /// `userId + itemKey IN (...)` lookup. + @@unique([userId, itemKey]) + @@map("dismissed_priority_items") +} + // ─── Medications ─────────────────────────────────────────── /// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the diff --git a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts new file mode 100644 index 000000000..1c291db9e --- /dev/null +++ b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts @@ -0,0 +1,164 @@ +/** + * `POST /api/daily/digest/dismiss` — the Today rail dismiss surface. + * + * Under test: cookie/Bearer auth narrows the user, the `insights` module gate + * returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't + * namespaced under a dismissible kind 422s BEFORE any DB write, and the happy + * path upserts the `DismissedPriorityItem` row scoped to the caller. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + dismissedPriorityItem: { + upsert: vi.fn().mockResolvedValue({}), + }, + }, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 39, + resetAt: Date.now() + 60_000, + })), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + locale: "en", + }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; +function makeReq(body: unknown): NextRequest { + return new NextRequest(new URL("http://localhost/api/daily/digest/dismiss"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("POST /api/daily/digest/dismiss", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null as never); + const res = await callPost( + makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }), + ); + expect(res.status).toBe(401); + expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + }); + + it("returns the 403 module.disabled envelope when insights is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callPost( + makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }), + ); + expect(res.status).toBe(403); + expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + }); + + it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => { + const res = await callPost(makeReq({ itemKey: "dose_window:anything" })); + expect(res.status).toBe(422); + expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + }); + + it("422s a missing itemKey", async () => { + const res = await callPost(makeReq({})); + expect(res.status).toBe(422); + }); + + it("422s an unknown extra field (strict schema)", async () => { + const res = await callPost( + makeReq({ + itemKey: "milestone:record_first:WEIGHT:2026-07-16", + userId: "someone-else", + }), + ); + expect(res.status).toBe(422); + expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + }); + + it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => { + for (const itemKey of [ + "milestone:record_first:WEIGHT:2026-07-16", + "ecg_new_recording:2026-07-16T08:00:00.000Z", + "tension_window:2026-07-16:afternoon", + ]) { + vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear(); + const res = await callPost(makeReq({ itemKey })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ dismissed: true }); + expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalledWith({ + where: { userId_itemKey: { userId: "user-1", itemKey } }, + update: {}, + create: { userId: "user-1", itemKey }, + }); + } + }); + + it("returns 429 when the caller's dismiss rate limit is exhausted", async () => { + const { checkRateLimit } = await import("@/lib/rate-limit"); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await callPost( + makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }), + ); + expect(res.status).toBe(429); + expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/daily/digest/dismiss/route.ts b/src/app/api/daily/digest/dismiss/route.ts new file mode 100644 index 000000000..4a0ba5d84 --- /dev/null +++ b/src/app/api/daily/digest/dismiss/route.ts @@ -0,0 +1,82 @@ +/** + * POST /api/daily/digest/dismiss + * + * "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem` + * kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The + * ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`, + * `coach_checkin`) are never reachable here: `dismissPriorityItemSchema` + * rejects any `itemKey` that doesn't carry one of the three dismissible + * kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`). + * + * Persisted server-side (`DismissedPriorityItem`, composite-unique on + * `userId` + `itemKey`) so the dismissal survives reload / a second device — + * an upsert, so a repeat dismiss of the same instance is a no-op rather than + * a conflict. `userId` is narrowed from `requireAuth()` only; the body never + * carries one. Cookie OR Bearer, mirroring the digest GET route's own auth + * policy, and gated on the same `insights` module (the daily rail's host + * surface). + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { dismissPriorityItemSchema } from "@/lib/validations/daily"; + +export const dynamic = "force-dynamic"; + +// A cheap, owner-scoped upsert; the limit only caps a runaway client loop. +const DISMISS_RATE_LIMIT = 40; +const DISMISS_WINDOW_MS = 60_000; + +export const POST = apiHandler(async (req: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "insights"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `daily-digest-dismiss:${user.id}`, + DISMISS_RATE_LIMIT, + DISMISS_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = dismissPriorityItemSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { itemKey } = parsed.data; + + await prisma.dismissedPriorityItem.upsert({ + where: { userId_itemKey: { userId: user.id, itemKey } }, + update: {}, + create: { userId: user.id, itemKey }, + }); + + annotate({ + action: { name: "daily.digest.item.dismissed" }, + meta: { daily_digest_item_kind: itemKey.split(":")[0] }, + }); + + return apiSuccess({ dismissed: true }); +}); diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index 7db766635..d588ae69f 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -134,4 +134,61 @@ describe("", () => { ); expect(html).toContain("View check-ups"); }); + + describe("dismiss affordance — observational kinds only", () => { + const DISMISSIBLE: PriorityItemKind[] = [ + "milestone", + "ecg_new_recording", + "tension_window", + ]; + const ACTIONABLE: PriorityItemKind[] = [ + "dose_window", + "sync_issue", + "preventive_care", + "coach_checkin", + ]; + + it("renders the dismiss button on every observational kind that carries an itemKey", () => { + for (const kind of DISMISSIBLE) { + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="priority-card-dismiss"'); + } + }); + + it("never renders the dismiss button on an actionable kind, even if a caller wires onDismiss", () => { + for (const kind of ACTIONABLE) { + const html = render( + {}} + />, + ); + expect(html).not.toContain('data-slot="priority-card-dismiss"'); + } + }); + + it("does not render the dismiss button when the item carries no itemKey (server didn't stamp one)", () => { + const html = render( + {}} + />, + ); + expect(html).not.toContain('data-slot="priority-card-dismiss"'); + }); + + it("does not render the dismiss button when the caller passes no onDismiss handler", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="priority-card-dismiss"'); + }); + }); }); diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index 71008f3f4..fab425ef5 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -26,6 +26,7 @@ import { MessageCircle, Pill, RefreshCw, + X, type LucideIcon, } from "lucide-react"; @@ -35,11 +36,12 @@ import { ProseBlocks } from "@/components/insights/prose-blocks"; import { Button, buttonVariants } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import type { - PriorityItem, - PriorityItemAction, - PriorityItemKind, - PriorityItemStatus, +import { + isDismissibleKind, + type PriorityItem, + type PriorityItemAction, + type PriorityItemKind, + type PriorityItemStatus, } from "@/lib/daily/priority-item"; /** Deterministic glyph per kind — the icon the serialisable item cannot hold. */ @@ -93,13 +95,27 @@ export interface PriorityCardProps { * links and do not call this. */ onAction?: (intent: string) => void; + /** + * Dismiss handler for the OBSERVATIONAL kinds only (`milestone`, + * `ecg_new_recording`, `tension_window`). Receives the item's own + * `itemKey`. The card renders the affordance ONLY when the kind is + * dismissible AND the item actually carries a key — an actionable item (no + * `itemKey` by construction) never shows one, no matter what the caller + * passes. + */ + onDismiss?: (itemKey: string) => void; className?: string; } /** Tap-target floor per P1: `min-h-11` on touch, `min-h-9` on pointer. */ const ACTION_SIZE = "min-h-11 sm:min-h-9"; -export function PriorityCard({ item, onAction, className }: PriorityCardProps) { +export function PriorityCard({ + item, + onAction, + onDismiss, + className, +}: PriorityCardProps) { const { t } = useTranslations(); const Icon = KIND_ICON[item.kind]; const actions = item.actions.slice(0, 3); @@ -111,6 +127,13 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) { // halo's background wins over the status wash's flat `bg-success/10` // (unlayered CSS beats utilities); the wash keeps the success border. const isMilestone = item.kind === "milestone"; + // Dismiss is offered ONLY on the observational kinds, and only once the + // server has actually stamped an identity onto this instance — the + // actionable kinds never carry an `itemKey`, so this is structurally false + // for them regardless of what the caller wires up. Held as its own const + // (rather than re-checked inline) so the narrowed `string` type carries + // into the click handler below without a non-null assertion. + const dismissKey = isDismissibleKind(item.kind) ? item.itemKey : undefined; return ( - + onDismiss(dismissKey)} + aria-label={t("common.dismiss")} + > +