diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6645c50c6..d11197593 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6052,6 +6052,47 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/biomarker-assessment: + get: + tags: + - Insights + summary: Per-biomarker assessment + description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. + Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a + cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the + assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` + without an LLM call. Auth via cookie or Bearer." + parameters: + - in: query + name: biomarkerId + schema: + type: string + minLength: 1 + maxLength: 64 + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + required: true + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -20286,6 +20327,67 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + BiomarkerAssessmentResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerAssessmentResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing, or when the marker has no numeric readings. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + insufficient: + description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not + rendered. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the + `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation + out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands." DerivedMetricResponseEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 67a310088..6431a39d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" - } + }, + "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "Noch kein Ruhepuls", "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat." - } + }, + "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?" }, "oxygenSaturation": { "title": "Sauerstoffsättigung", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarker gelöscht", "deleteError": "Biomarker konnte nicht gelöscht werden", "deleteConfirmTitle": "Biomarker löschen?", - "deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.", + "deleteConfirmDescription": "Der Biomarker und alle erfassten Werte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "emptyTitle": "Noch keine Biomarker", "emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.", "loadError": "Biomarker konnten nicht geladen werden", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarker wiederhergestellt", "hideError": "Biomarker konnte nicht aktualisiert werden", "allHidden": "Alle Biomarker sind ausgeblendet.", - "hiddenSection": "Ausgeblendet" + "hiddenSection": "Ausgeblendet", + "targetRange": { + "title": "Zielbereich anpassen", + "description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.", + "save": "Bereich speichern", + "savedToast": "Zielbereich aktualisiert", + "saveError": "Zielbereich konnte nicht aktualisiert werden" + } }, "catalog": { "total-cholesterol": "Gesamtcholesterin", diff --git a/messages/en.json b/messages/en.json index f46a12505..61a645914 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.", "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" - } + }, + "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "No resting heart rate yet", "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day." - } + }, + "coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" }, "oxygenSaturation": { "title": "Oxygen saturation", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarker deleted", "deleteError": "Could not delete the biomarker", "deleteConfirmTitle": "Delete biomarker?", - "deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.", + "deleteConfirmDescription": "The biomarker and all of its recorded values are permanently deleted. This cannot be undone.", "emptyTitle": "No biomarkers yet", "emptyDescription": "Define markers like LDL or HbA1c to record values against them.", "loadError": "Could not load biomarkers", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarker restored", "hideError": "Could not update the biomarker", "allHidden": "Every biomarker is hidden.", - "hiddenSection": "Hidden" + "hiddenSection": "Hidden", + "targetRange": { + "title": "Adjust target range", + "description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.", + "save": "Save range", + "savedToast": "Target range updated", + "saveError": "Could not update the target range" + } }, "catalog": { "total-cholesterol": "Total cholesterol", diff --git a/messages/es.json b/messages/es.json index 710636482..8252590e1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.", "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" - } + }, + "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "Aún no hay FC en reposo", "description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo." - } + }, + "coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?" }, "oxygenSaturation": { "title": "Saturación de oxígeno", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarcador eliminado", "deleteError": "No se pudo eliminar el biomarcador", "deleteConfirmTitle": "¿Eliminar biomarcador?", - "deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.", + "deleteConfirmDescription": "El biomarcador y todos sus valores registrados se eliminan de forma permanente. Esta acción no se puede deshacer.", "emptyTitle": "Aún no hay biomarcadores", "emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.", "loadError": "No se pudieron cargar los biomarcadores", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarcador restaurado", "hideError": "No se pudo actualizar el biomarcador", "allHidden": "Todos los biomarcadores están ocultos.", - "hiddenSection": "Ocultos" + "hiddenSection": "Ocultos", + "targetRange": { + "title": "Ajustar el rango objetivo", + "description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.", + "save": "Guardar rango", + "savedToast": "Rango objetivo actualizado", + "saveError": "No se pudo actualizar el rango objetivo" + } }, "catalog": { "total-cholesterol": "Colesterol total", diff --git a/messages/fr.json b/messages/fr.json index 69612814d..f37d675e6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.", "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" - } + }, + "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "Aucune FC au repos", "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme." - } + }, + "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?" }, "oxygenSaturation": { "title": "Saturation en oxygène", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarqueur supprimé", "deleteError": "Impossible de supprimer le biomarqueur", "deleteConfirmTitle": "Supprimer le biomarqueur ?", - "deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.", + "deleteConfirmDescription": "Le biomarqueur et toutes ses valeurs enregistrées sont définitivement supprimés. Cette action est irréversible.", "emptyTitle": "Aucun biomarqueur pour l'instant", "emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.", "loadError": "Impossible de charger les biomarqueurs", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarqueur restauré", "hideError": "Impossible de mettre à jour le biomarqueur", "allHidden": "Tous les biomarqueurs sont masqués.", - "hiddenSection": "Masqués" + "hiddenSection": "Masqués", + "targetRange": { + "title": "Ajuster la plage cible", + "description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.", + "save": "Enregistrer la plage", + "savedToast": "Plage cible mise à jour", + "saveError": "Impossible de mettre à jour la plage cible" + } }, "catalog": { "total-cholesterol": "Cholestérol total", diff --git a/messages/it.json b/messages/it.json index c692d09be..55fcc02fc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.", "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" - } + }, + "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "Ancora nessuna FC a riposo", "description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla." - } + }, + "coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?" }, "oxygenSaturation": { "title": "Saturazione di ossigeno", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarcatore eliminato", "deleteError": "Impossibile eliminare il biomarcatore", "deleteConfirmTitle": "Eliminare il biomarcatore?", - "deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.", + "deleteConfirmDescription": "Il biomarcatore e tutti i suoi valori registrati vengono eliminati definitivamente. L'operazione non è reversibile.", "emptyTitle": "Ancora nessun biomarcatore", "emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.", "loadError": "Impossibile caricare i biomarcatori", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarcatore ripristinato", "hideError": "Impossibile aggiornare il biomarcatore", "allHidden": "Tutti i biomarcatori sono nascosti.", - "hiddenSection": "Nascosti" + "hiddenSection": "Nascosti", + "targetRange": { + "title": "Regola l'intervallo obiettivo", + "description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.", + "save": "Salva intervallo", + "savedToast": "Intervallo obiettivo aggiornato", + "saveError": "Impossibile aggiornare l'intervallo obiettivo" + } }, "catalog": { "total-cholesterol": "Colesterolo totale", diff --git a/messages/pl.json b/messages/pl.json index 356f0d949..206cfb8ab 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2387,7 +2387,8 @@ "correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.", "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" - } + }, + "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -2459,7 +2460,8 @@ "emptyState": { "title": "Brak tętna spoczynkowego", "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku." - } + }, + "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?" }, "oxygenSaturation": { "title": "Saturacja tlenu", @@ -6991,7 +6993,7 @@ "deletedToast": "Biomarker usunięty", "deleteError": "Nie udało się usunąć biomarkera", "deleteConfirmTitle": "Usunąć biomarker?", - "deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.", + "deleteConfirmDescription": "Biomarker i wszystkie jego zapisane wartości zostaną trwale usunięte. Tej operacji nie można cofnąć.", "emptyTitle": "Brak biomarkerów", "emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.", "loadError": "Nie udało się wczytać biomarkerów", @@ -7026,7 +7028,14 @@ "unhiddenToast": "Biomarker przywrócony", "hideError": "Nie udało się zaktualizować biomarkera", "allHidden": "Wszystkie biomarkery są ukryte.", - "hiddenSection": "Ukryte" + "hiddenSection": "Ukryte", + "targetRange": { + "title": "Dostosuj zakres docelowy", + "description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.", + "save": "Zapisz zakres", + "savedToast": "Zaktualizowano zakres docelowy", + "saveError": "Nie udało się zaktualizować zakresu docelowego" + } }, "catalog": { "total-cholesterol": "Cholesterol całkowity", diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..bb3f0a75d --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }, + labResult: { deleteMany: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), + }, +})); + +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/labs/biomarker-store", () => ({ + encryptContextToBytes: vi.fn(() => new Uint8Array([1])), + decryptContextFromBytes: vi.fn(() => "ctx"), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT, DELETE } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const EXISTING = { + id: "bm-1", + userId: "user-1", + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hidden: false, + contextEncrypted: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "PUT", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation((async ( + ops: Promise[], + ) => Promise.all(ops)) as never); +}); + +describe("DELETE /api/biomarkers/[id]", () => { + it("drops the marker and its readings in one userId-narrowed transaction", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({ + count: 3, + } as never); + vi.mocked(prisma.biomarker.delete).mockResolvedValue(EXISTING as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } | null }; + expect(body.data?.deleted).toBe(true); + + expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", biomarkerId: "bm-1" }, + }); + expect(prisma.biomarker.delete).toHaveBeenCalledWith({ + where: { id: "bm-1" }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it("404s a cross-user marker without deleting anything", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(404); + expect(prisma.labResult.deleteMany).not.toHaveBeenCalled(); + expect(prisma.biomarker.delete).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/biomarkers/[id] — target range", () => { + it("persists an updated reference window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.biomarker.update).mockResolvedValue({ + ...EXISTING, + lowerBound: 10, + upperBound: 50, + } as never); + + const res = await PUT( + putReq({ lowerBound: 10, upperBound: 50 }), + params("bm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lowerBound: number; upperBound: number } | null; + }; + expect(body.data?.lowerBound).toBe(10); + expect(body.data?.upperBound).toBe(50); + + const updateCall = vi.mocked(prisma.biomarker.update).mock.calls[0][0] as { + data: Record; + }; + expect(updateCall.data.lowerBound).toBe(10); + expect(updateCall.data.upperBound).toBe(50); + }); + + it("422s an inverted target range without updating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + + const res = await PUT( + putReq({ lowerBound: 80, upperBound: 20 }), + params("bm-1"), + ); + expect(res.status).toBe(422); + expect(prisma.biomarker.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3b533241b..89dcce4c6 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -24,10 +24,10 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; * * PUT applies a partial edit (`data` built field-by-field; an explicit `null` * clears `context` / `panel` / a bound, an omitted key leaves it untouched). - * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on - * `LabResult.biomarkerId` unlinks every reading without losing it (the row - * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows - * surface as 404 (existence sealed). + * DELETE hard-deletes the catalog definition together with every reading it + * owns, in one userId-narrowed transaction — removing a biomarker is a + * deliberate "drop this and its values" action. Cross-user rows surface as + * 404 (existence sealed). */ type RouteParams = { params: Promise<{ id: string }> }; @@ -187,10 +187,17 @@ export const DELETE = apiHandler( return apiError("Biomarker not found", 404); } - // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather - // than cascading it away. The readings keep their legacy `analyte` / - // `unit` so the history survives the catalog edit. - await prisma.biomarker.delete({ where: { id } }); + // Hard delete the marker AND its readings in one transaction. Deleting a + // biomarker is a deliberate "remove this and its history" action, so the + // readings go with it rather than lingering as orphaned, unlinked rows. + // Both legs are userId-narrowed so a cross-user id can never reach another + // account's data. + await prisma.$transaction([ + prisma.labResult.deleteMany({ + where: { userId: user.id, biomarkerId: id }, + }), + prisma.biomarker.delete({ where: { id } }), + ]); await auditLog("biomarker.delete", { userId: user.id, diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 5e94255c4..1aa7bbe5b 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest"; const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // Non-Coach assistant surfaces — gated on a sibling sub-flag. + // Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as + // the metric-status + specialised status routes (no Coach prose). + "src/app/api/insights/biomarker-assessment/route.ts", "src/app/api/insights/blood-pressure-status/route.ts", "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts new file mode 100644 index 000000000..49f419399 --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +// The route resolves the `insights` module gate after `requireAuth()`. Mock +// it default-enabled so the assertions ride through; the off → 403 coverage +// lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the real +// status pipeline. +vi.mock("@/lib/insights/biomarker-status", () => ({ + generateBiomarkerStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), +})); + +vi.mock("@/lib/insights/metric-status", () => ({ + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(biomarkerId?: string): NextRequest { + const url = new URL("http://localhost/api/insights/biomarker-assessment"); + if (biomarkerId !== undefined) + url.searchParams.set("biomarkerId", biomarkerId); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/insights/biomarker-assessment", () => { + it("returns 200 + envelope and calls the generator read-only", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateBiomarkerStatus).toHaveBeenCalledWith( + expect.objectContaining({ + biomarkerId: "bm-1", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on a missing biomarkerId without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateBiomarkerStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts new file mode 100644 index 000000000..1bc0bd5af --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -0,0 +1,72 @@ +/** + * Per-biomarker assessment route. + * + * `GET /api/insights/biomarker-assessment?biomarkerId=` serves the + * data-driven assessment for one user-scoped biomarker. The shape is + * byte-identical to `/api/insights/metric-status` so `InsightStatusCard` + * consumes it unchanged. + * + * Read-only by construction: a cache miss enqueues an out-of-band + * generation (via `resolveReadOnlyStatusMiss` inside the generator) and + * serves the last-good text (stale-while-revalidate), never blocking on the + * provider. The generator regenerates ONLY when the latest reading + * fingerprint changes, so an idle marker re-stamps cached text without an + * LLM round-trip. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; +import { resolveMetricStatusLocale } from "@/lib/insights/metric-status"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + biomarkerId: z.string().min(1).max(64), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + await requireAssistantSurface("insightStatus"); + + const parsed = querySchema.safeParse({ + biomarkerId: request.nextUrl.searchParams.get("biomarkerId"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.biomarker-status.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const localeParam = request.nextUrl.searchParams.get("locale"); + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: localeParam, + }); + const locale = resolveMetricStatusLocale(resolved); + + const result = await generateBiomarkerStatus({ + biomarkerId: parsed.data.biomarkerId, + userId: user.id, + locale, + force: false, + readOnly: true, + }); + + annotate({ + action: { name: "insights.biomarker-status" }, + meta: { biomarkerId: parsed.data.biomarkerId }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index ccf61598a..95a16af3a 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -3,6 +3,7 @@ import { Heart } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.32 — `/insights/resting-pulse`. @@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * trend that's not muddied by mid-walk readings. */ export default function InsightsRestingHrPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + coachPrefill={t("insights.restingHr.coachPrefill")} /> ); } diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index 1200ab309..ffc97490d 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -44,6 +44,12 @@ export interface AskCoachActionProps { * briefing), which read better against the default all-source snapshot. */ scope?: CoachLaunchScope; + /** + * Auto-send the seeded question as the conversation's first turn instead + * of only seeding the composer. The assessment hand-off sets this so the + * Coach answers immediately; defaults to false (seed-only). + */ + autoSend?: boolean; /** Optional visible-label override; defaults to the shared CTA copy. */ label?: string; /** className passthrough for per-card alignment. */ @@ -53,6 +59,7 @@ export interface AskCoachActionProps { export function AskCoachAction({ question, scope, + autoSend, label, className, }: AskCoachActionProps) { @@ -76,7 +83,7 @@ export function AskCoachAction({ variant="ghost" size="sm" data-slot="ask-coach-action" - onClick={() => launch.askCoach(question, scope)} + onClick={() => launch.askCoach(question, scope, autoSend)} className={cn( // Discreet: muted text, hover lifts to foreground, no fill. Tight // height so it sits as a quiet footer link, not a primary CTA. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 5a9236f6d..0432e6a77 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -117,6 +117,13 @@ export interface CoachConversationProps { * established scope. Null → the route's default all-source snapshot. */ launchScope?: CoachLaunchScope | null; + /** + * When true, the `prefill` is dispatched as the conversation's first turn + * automatically, exactly once on mount (ref-guarded). Used by the + * assessment hand-off so the answer lands without a manual send. The send + * only fires for a fresh conversation with a non-empty prefill. + */ + autoSend?: boolean; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -188,6 +195,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, launchScope, + autoSend, renderTitle, renderDescription, leadingHeaderActions, @@ -340,6 +348,33 @@ export function CoachConversation({ } } + // Auto-send the prefill as the conversation's first turn, exactly once. + // A card hand-off (e.g. the assessment "ask about this") opens the Coach + // with `autoSend` so the answer lands without a manual send. Ref-guarded so + // it fires a single time per mount; only for a fresh conversation with a + // non-empty prefill and no stream in flight. + const autoSentRef = useRef(false); + // Latest-handler ref so the auto-send effect never lists `handleSubmit` (it + // re-creates each render) and never calls it synchronously in the effect + // body — the dispatch is deferred to a microtask so the composer state it + // touches settles outside the effect. + const handleSubmitRef = useRef(handleSubmit); + useEffect(() => { + handleSubmitRef.current = handleSubmit; + }); + useEffect(() => { + if (!autoSend) return; + if (autoSentRef.current) return; + if (currentConversationId !== null) return; + if (send.isStreaming) return; + const seed = (prefill ?? "").trim(); + if (!seed) return; + autoSentRef.current = true; + queueMicrotask(() => { + void handleSubmitRef.current(seed); + }); + }, [autoSend, prefill, currentConversationId, send.isStreaming]); + // v1.22 — "Try again": regenerate an assistant reply by resubmitting the // user turn that produced it as a FRESH turn. The composer value is left // untouched (unlike `handleSubmit`, which clears it) so a half-typed diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 490018b21..98e21ef4e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -54,6 +54,11 @@ export interface CoachDrawerProps { * source(s) + window. Null → the route's default all-source snapshot. */ scope?: CoachLaunchScope | null; + /** + * When true, the conversation auto-sends `prefill` as its first turn + * exactly once on open. Used by the assessment hand-off. + */ + autoSend?: boolean; } export function CoachDrawer({ @@ -61,6 +66,7 @@ export function CoachDrawer({ onOpenChange, prefill, scope, + autoSend, }: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); @@ -139,6 +145,7 @@ export function CoachDrawer({ surface="drawer" prefill={prefill} launchScope={scope} + autoSend={autoSend} autoFocusComposer registerReset={registerReset} // v1.16.1 — the "Conversations" affordance hands off to a diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index f36c20ca9..03eeca56e 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -69,6 +69,11 @@ interface InsightStatusCardProps { */ coachQuestion?: string; coachScope?: CoachLaunchScope; + /** + * Auto-send the seeded opener as the first turn (assessment hand-off) + * instead of only seeding the composer. Defaults to false. + */ + coachAutoSend?: boolean; } // ─── Main Component ─────────────────────────────────────── @@ -83,6 +88,7 @@ export function InsightStatusCard({ preparing = false, coachQuestion, coachScope, + coachAutoSend, }: InsightStatusCardProps) { const { t } = useTranslations(); const flags = useFeatureFlags(); @@ -248,7 +254,11 @@ export function InsightStatusCard({ caller supplied an opener. */} {coachQuestion ? (
- +
) : null} diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 1b0947430..b8e8be6be 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -56,6 +56,9 @@ export function LayoutCoachMount() { // opened from a metric surface or insight card is pre-narrowed to // the relevant source(s). scope={launch.scope} + // Auto-send the prefill as the first turn when the launch requested it + // (assessment hand-off), so the answer lands without a manual send. + autoSend={launch.autoSend} /> ); } diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 5644db56b..524401a96 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -46,12 +46,9 @@ export function MetricStatusCard({ // carry a snapshot block. Unmapped metrics still get a metric-named // opener, just against the default snapshot. const coachSource = scopeSourceFromMetricKey(metric); - const coachQuestion = `Walk me through my ${metric - .toLowerCase() - .replace( - /_/g, - " ", - )} assessment — what does it mean and what should I do about it?`; + const coachQuestion = t("insights.coach.assessmentPrompt", { + metric: metric.toLowerCase().replace(/_/g, " "), + }); return ( `: the hook is // `enabled: isAuthenticated && enabled`, and both inputs can settle // before a late-hydrating boundary replays its first render — the diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx new file mode 100644 index 000000000..8c6b6ab0b --- /dev/null +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { + BiomarkerDto, + LabResultDto, + LabResultListResponse, +} from "../types"; + +/** + * The per-biomarker detail page mirrors the metric sub-pages: a numbers-first + * stat strip over the numeric readings, the (lazy) chart, the history, and the + * AI assessment card. This test seeds the marker + reading queries and asserts + * the strip, the chart placeholder, and the assessment card all paint. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), + apiPut: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => ({ insightStatus: true }), +})); + +import { LabBiomarkerDetail } from "../lab-biomarker-detail"; + +const BIOMARKER_ID = "bm-1"; + +const MARKER: BiomarkerDto = { + id: BIOMARKER_ID, + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hasContext: false, + context: null, + hidden: false, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", +}; + +function reading(id: string, value: number, takenAt: string): LabResultDto { + return { + id, + biomarkerId: BIOMARKER_ID, + panel: null, + analyte: "LDL Cholesterol", + value, + valueText: null, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 100, + takenAt, + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: takenAt, + updatedAt: takenAt, + }; +} + +const LIST: LabResultListResponse = { + results: [ + reading("r3", 95, "2026-06-20T08:00:00.000Z"), + reading("r2", 88, "2026-05-20T08:00:00.000Z"), + reading("r1", 102, "2026-04-20T08:00:00.000Z"), + ], + meta: { total: 3, limit: 500, offset: 0 }, +}; + +function renderWithCache() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER); + queryClient.setQueryData( + queryKeys.labResultsList({ + biomarkerId: BIOMARKER_ID, + analyte: undefined, + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", + }), + LIST, + ); + queryClient.setQueryData( + queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"), + { + hasProvider: true, + text: "Your LDL is steady inside the reference range.", + cached: true, + updatedAt: "2026-06-20T09:00:00.000Z", + }, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + const html = renderWithCache(); + + it("paints the marker heading", () => { + expect(html).toContain("LDL Cholesterol"); + }); + + it("paints the numbers-first stat strip", () => { + expect(html).toContain('data-slot="metric-stat-strip"'); + }); + + it("paints the chart placeholder (lazy chart shell)", () => { + // The recharts chart is `next/dynamic({ ssr: false })`; the server render + // paints the layout-stable skeleton shell in its place. + expect(html).toContain('data-slot="skeleton"'); + }); + + it("paints the assessment card", () => { + // useMounted() is false during SSR, so the card renders its loading + // skeleton geometry — proof the assessment card mounted on the page. + expect(html).toContain('data-testid="insight-status-card-loading"'); + }); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 0db4c7ed4..f4ddab48a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,18 +1,26 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Pencil, Plus } from "lucide-react"; +import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { summarize, type DataSummary } from "@/lib/analytics/trends"; +import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -65,11 +73,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + // v1.24 — the per-marker description used to live on the labs overview rows. + // It belongs on the detail page beneath the heading (mirroring the metric + // pages' explainer caption). Resolve the catalog slug from the marker name + // exactly as the overview did, then fall back to the user's own `context`. + const slugByName = useMemo(() => { + const map = new Map(); + for (const seed of BIOMARKER_CATALOG) { + const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); + if (norm) map.set(norm, seed.slug); + } + return map; + }, [t]); const [addOpen, setAddOpen] = useState(false); // Sticky-footer slot for the add-value sheet (the form portals here). const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(null); + // v1.24 — focused "adjust target range" sheet. Edits only the reference + // bounds (the full marker editor lives behind the pencil); seeded from the + // marker each time it opens. + const [rangeOpen, setRangeOpen] = useState(false); + const [lowerInput, setLowerInput] = useState(""); + const [upperInput, setUpperInput] = useState(""); const { data: marker, isError: markerError } = useQuery({ queryKey: queryKeys.biomarkerDetail(biomarkerId), @@ -96,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ), }); - const readings: LabResultDto[] = list?.results ?? []; + const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]); // The reading feed caps at 500 server-side; surface a hint when truncated. const total = list?.meta?.total ?? 0; const truncated = total > readings.length; @@ -108,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { )[0] : null; + // v1.24 — the numbers-first stat strip + the AI assessment card mirror the + // metric sub-pages. The strip reads a client-side `DataSummary` over the + // numeric readings; the assessment card consumes the read-only biomarker + // route (stale-while-revalidate, regenerated only on a new reading). The + // strip + card both self-gate on data, so a qualitative-only or brand-new + // marker paints neither. + const numericReadings = useMemo( + () => readings.filter((r) => r.value !== null), + [readings], + ); + const summary = useMemo( + () => + numericReadings.length > 0 + ? summarize( + numericReadings.map((r) => ({ + date: new Date(r.takenAt), + value: r.value as number, + })), + ) + : null, + [numericReadings], + ); + const mounted = useMounted(); + const { data: assessment, isLoading: assessmentLoading } = + useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0); + function afterAdd() { setAddOpen(false); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); @@ -138,6 +190,33 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onError: () => toast.error(t("labs.biomarker.deleteError")), }); + function openRange() { + setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : ""); + setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : ""); + setRangeOpen(true); + } + + const saveRange = useMutation({ + mutationFn: () => { + const lower = lowerInput.trim() === "" ? null : Number(lowerInput); + const upper = upperInput.trim() === "" ? null : Number(upperInput); + return apiPut(`/api/biomarkers/${biomarkerId}`, { + lowerBound: lower, + upperBound: upper, + }); + }, + onSuccess: () => { + toast.success(t("labs.biomarker.targetRange.savedToast")); + setRangeOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.biomarkerDetail(biomarkerId), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + }, + onError: () => toast.error(t("labs.biomarker.targetRange.saveError")), + }); + if (markerError || listError) { return (

@@ -189,6 +268,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" iconClassName="h-4 w-4" /> + + + + } + > +

+
+ + setLowerInput(e.target.value)} + /> +
+
+ + setUpperInput(e.target.value)} + /> +
+
+ {marker?.unit ? ( +

+ {t("labs.biomarker.form.rangeHint")} +

+ ) : null} + ); } diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 98b072074..17cfb3e50 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -1,17 +1,26 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; -import { DeleteButton } from "@/components/data-list"; +import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import { useTableSort } from "@/hooks/use-table-sort"; import { ApiError, apiDelete, @@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types"; const NOTE_MAX_LENGTH = 2000; +// The sample-date column opens descending (newest first); every other column +// opens ascending. +const LAB_DESC_COLUMNS = new Set(["takenAt"]); + +// Stable ordering for the reference-range column so a sort produces a +// predictable below → in-range → above → unknown progression. +const RANGE_RANK: Record = { + below: 0, + "in-range": 1, + above: 2, + unknown: 3, +}; + function toDateTimeLocal(iso: string): string { const d = new Date(iso); const offset = d.getTimezoneOffset(); @@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // Client-side column sort over the in-memory reading feed. + const { sortBy, sortDir, toggleSort } = useTableSort({ + defaultColumn: "takenAt", + defaultDir: "desc", + descColumns: LAB_DESC_COLUMNS, + }); + const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); // v1.18.9 — true when the row being edited is a qualitative reading; the @@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); } - const ordered = [...readings].sort( - (a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), - ); + const ordered = useMemo(() => { + const dir = sortDir === "asc" ? 1 : -1; + return [...readings].sort((a, b) => { + let cmp = 0; + switch (sortBy) { + case "value": { + // Numeric rows compare by value; qualitative rows (value null) sort + // after the numeric ones and tie-break on their text. + if (a.value !== null && b.value !== null) { + cmp = a.value - b.value; + } else if (a.value === null && b.value === null) { + cmp = (a.valueText ?? "").localeCompare(b.valueText ?? ""); + } else { + cmp = a.value === null ? 1 : -1; + } + break; + } + case "range": + cmp = + (RANGE_RANK[a.rangeStatus] ?? 99) - + (RANGE_RANK[b.rangeStatus] ?? 99); + break; + case "note": + cmp = Number(a.hasNote) - Number(b.hasNote); + break; + case "takenAt": + default: + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + break; + } + // Stable secondary ordering by sample date keeps ties deterministic. + if (cmp === 0) { + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + } + return cmp * dir; + }); + }, [readings, sortBy, sortDir]); return ( <> -
    - {ordered.map((r) => ( -
  • -
    -
    - - {formatLabReading(r)} - - -
    -

    + + + + + + + + + {t("labs.editReading")} + + + + + {ordered.map((r) => ( + + + {formatLabReading(r)} + + {formatDateShort(r.takenAt, true)} - {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} -

    - -
    - - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
    - - ))} - +
    + + + + + {r.hasNote ? t("labs.hasNote") : ""} + + +
    + + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
    +
    +
    + ))} +
    +
    void } = {}) { // v1.22 — a short, factual line under each marker heading describing what the // biomarker measures, sourced from the catalog via i18n. Free-text markers // (no catalog match) carry no subtitle rather than a fabricated one. - // v1.22.1 — build the localized name→slug map once per locale instead of - // re-scanning all catalog seeds (calling `t()` each) per group every render. - const slugByName = useMemo(() => { - const map = new Map(); - for (const seed of BIOMARKER_CATALOG) { - const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); - if (norm) map.set(norm, seed.slug); - } - return map; - }, [t]); - const describeMarker = (analyte: string): string | null => { - const norm = analyte.trim().toLowerCase(); - if (!norm) return null; - const slug = slugByName.get(norm); - return slug ? t(`labs.catalog.desc.${slug}`) : null; - }; - + // v1.24 — the per-marker description moved to the biomarker detail page + // (beneath the heading); the overview rows no longer carry it. const listKey = queryKeys.labResultsList({ biomarkerId: undefined, analyte: undefined, @@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {groups.map((group) => { - const description = describeMarker(group.analyte); const inner = (

    @@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { compact />
    - {description ? ( -

    - {description} -

    - ) : null}
    {formatLabReading(group.latest)} @@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
  • ) : null} {groups.map((group) => { - const description = describeMarker(group.analyte); return (
  • @@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { linkLabel={group.analyte} /> - {description ? ( -

    - {description} -

    - ) : null}
    diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 58a194307..8d7f918eb 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -52,6 +52,7 @@ import { MoreHorizontal, } from "lucide-react"; import { Fragment, useCallback, useId, useState } from "react"; +import { useTableSort } from "@/hooks/use-table-sort"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; @@ -105,6 +106,10 @@ import { */ const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); +// Columns that open descending when first selected. The date column reads +// newest-first by default; every other column opens ascending. +const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set(["measuredAt"]); + interface Measurement { id: string; type: string; @@ -313,8 +318,16 @@ export function MeasurementList({ const [valueMinInput, setValueMinInputRaw] = useState(""); const [valueMaxInput, setValueMaxInputRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("measuredAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (measuredAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "measuredAt", + defaultDir: "desc", + descColumns: MEASUREMENT_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select. Holds the ids selected on the // CURRENT page; cleared on any page / filter / sort change (per the @@ -399,13 +412,11 @@ export function MeasurementList({ clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle (the rows the selection referred to are about to + // re-order and the list snaps back to page 1). function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "measuredAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index d5a90d6f4..fc8f92199 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -52,6 +52,11 @@ import { } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useTableSort } from "@/hooks/use-table-sort"; + +// Columns that open descending when first selected. The logged-at column +// reads newest-first by default; every other column opens ascending. +const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -129,8 +134,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [fromDay, setFromDayRaw] = useState(""); const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("moodLoggedAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (moodLoggedAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "moodLoggedAt", + defaultDir: "desc", + descColumns: MOOD_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select selection (current page ids only). const [selectedIds, setSelectedIds] = useState>(() => new Set()); @@ -202,13 +215,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle. function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "moodLoggedAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index d340cd270..7f94e49ad 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -241,3 +241,57 @@ export function useInsightMetricStatus( ), }); } + +/** + * Per-biomarker assessment loader for the lab-marker detail page. Mirrors + * `useInsightMetricStatus` exactly — the same `InsightStatusData` envelope, + * the 8 s client ceiling, the no-retry policy, and the bounded `preparing` + * poll — but keyed by the marker id against + * `GET /api/insights/biomarker-assessment?biomarkerId=…`. + * + * Read-only + stale-while-revalidate: the route serves cached text and warms + * a regeneration out of band on a cache miss, exactly like every metric + * page. Generation never warms on mount beyond that shared seam; the worker + * cron + the read-only enqueue are the only producers. + * + * `enabled` lets the page suppress the fetch when the marker has no readings + * (the page renders its empty state instead of the card), so a brand-new + * marker never fires an assessment round-trip. + */ +export function useInsightBiomarkerAssessment( + biomarkerId: string, + enabled = true, +) { + const { isAuthenticated } = useAuth(); + const { locale } = useTranslations(); + + return useQuery({ + queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale), + queryFn: async (): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + STATUS_TIMEOUT_MS, + ); + try { + return await apiGet( + `/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent( + biomarkerId, + )}&locale=${locale}`, + { signal: controller.signal }, + ); + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: isAuthenticated && enabled && biomarkerId.length > 0, + staleTime: 60 * 1000, + retry: 0, + refetchInterval: (query) => + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + query.state.data?.revalidating, + ), + }); +} diff --git a/src/hooks/use-table-sort.ts b/src/hooks/use-table-sort.ts new file mode 100644 index 000000000..776b33b85 --- /dev/null +++ b/src/hooks/use-table-sort.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback, useState } from "react"; + +export type SortDirection = "asc" | "desc"; + +/** + * Shared column-sort state for the data-management tables (measurements, + * mood, lab history). Lifted from the byte-identical `sortBy` / `sortDir` / + * `toggleSort` triple the measurement + mood lists each carried inline. + * + * `toggleSort(column)` flips the direction when the active column is tapped + * again, otherwise switches to the new column with its default direction: + * a column listed in `descColumns` (typically the date column) opens + * descending, every other column ascending. Callers that need extra side + * effects on a sort change (reset the page, clear a selection) compose them + * around the returned `toggleSort` rather than re-implementing the math. + */ +export function useTableSort({ + defaultColumn, + defaultDir = "desc", + descColumns, +}: { + /** Column the table sorts by on first render. */ + defaultColumn: string; + /** Direction for the initial column. */ + defaultDir?: SortDirection; + /** Columns that open descending when first selected (e.g. the date column). */ + descColumns?: ReadonlySet; +}): { + sortBy: string; + sortDir: SortDirection; + toggleSort: (column: string) => void; +} { + const [sortBy, setSortBy] = useState(defaultColumn); + const [sortDir, setSortDir] = useState(defaultDir); + + const toggleSort = useCallback( + (column: string) => { + if (sortBy === column) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortBy(column); + setSortDir(descColumns?.has(column) ? "desc" : "asc"); + } + }, + [sortBy, descColumns], + ); + + return { sortBy, sortDir, toggleSort }; +} diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts new file mode 100644 index 000000000..1c677c606 --- /dev/null +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn() }, + labResult: { findMany: vi.fn(), count: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + generateBiomarkerStatus, + biomarkerStatusScope, +} from "../biomarker-status"; + +const MARKER = { + id: "bm-1", + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, +}; + +const TAKEN_AT = new Date("2026-06-20T08:00:00.000Z"); + +function stubCompletion(content: string, capture?: { userPrompt: string }) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +/** The fingerprint the generator computes for a single-reading marker. */ +function inputHashFor(reading: { + id: string; + value: number; + takenAt: Date; +}): string { + return hashInsightSnapshot({ + scope: biomarkerStatusScope(MARKER.id), + locale: "en", + promptVersion: PROMPT_VERSION, + count: 1, + bounds: { lower: MARKER.lowerBound, upper: MARKER.upperBound }, + latest: { + id: reading.id, + takenAt: reading.takenAt.toISOString(), + value: reading.value, + }, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-06-20T09:00:00.000Z"), + } as never); +}); + +describe("generateBiomarkerStatus — empty-data guard", () => { + it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.labResult.count).not.toHaveBeenCalled(); + }); + + it("returns insufficient for an unknown / cross-user marker", async () => { + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: "nope", + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(runStatusCompletion).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — cache read", () => { + it("serves today's cached text without calling the provider", async () => { + const todayKey = toBerlinDayKey(new Date()); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Your LDL is steady.", + model: "x", + }), + } as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(result.cached).toBe(true); + expect(result.text).toBe("Your LDL is steady."); + expect(runStatusCompletion).not.toHaveBeenCalled(); + // Cache hit short-circuits before the reading read. + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — generation path", () => { + it("builds a snapshot, runs the completion, and persists with an inputHash", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const capture = { userPrompt: "" }; + stubCompletion( + '{"summary":"Your LDL sits inside the reference range."}', + capture, + ); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Your LDL sits inside the reference range."); + expect(result.cached).toBe(false); + expect(result.hasProvider).toBe(true); + + // The snapshot carries the marker metadata + the latest reading. + const match = capture.userPrompt.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + expect(snapshot.marker.name).toBe("LDL Cholesterol"); + expect(snapshot.marker.unit).toBe("mg/dL"); + expect(snapshot.latest.value).toBe(95); + + // Persisted under the biomarker scope cache action, carrying the inputHash. + const createCall = vi + .mocked(prisma.auditLog.create) + .mock.calls.at(-1)![0] as { + data: { action: string; details: string }; + }; + expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en"); + const persisted = JSON.parse(createCall.data.details); + expect(persisted.inputHash).toBe(inputHashFor(reading)); + }); +}); + +describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => { + it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + // A prior assessment from an earlier day whose inputHash matches the + // current latest reading — readFreshStatusText misses on the day key, but + // the input gate matches the fingerprint. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stable LDL from a previous reading.", + model: "x", + inputHash: inputHashFor(reading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.cached).toBe(true); + expect(result.text).toBe("Stable LDL from a previous reading."); + }); + + it("regenerates when a new reading lands (inputHash differs)", async () => { + const oldReading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + const newReading = { + id: "r2", + value: 130, + takenAt: new Date("2026-06-25T08:00:00.000Z"), + }; + // The cached row's inputHash is for the OLD reading; the live latest is the + // new reading, so the fingerprint differs and the gate misses. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stale assessment.", + model: "x", + inputHash: inputHashFor(oldReading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + newReading, + ] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + stubCompletion('{"summary":"LDL rose above the reference range."}'); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("LDL rose above the reference range."); + expect(result.cached).toBe(false); + }); +}); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts new file mode 100644 index 000000000..39e867291 --- /dev/null +++ b/src/lib/insights/biomarker-status.ts @@ -0,0 +1,362 @@ +/** + * Per-biomarker assessment generator. + * + * Mirrors `generateMetricStatus` (the generic HealthKit-metric generator) + * but reads `LabResult` rows for one user-scoped `Biomarker` instead of + * `Measurement` rows for a `MeasurementType`. It reuses the exact same + * machinery the metric cards use: + * - the shared provider runner (`runStatusCompletion`), + * - the stale-while-revalidate read-only path (`resolveReadOnlyStatusMiss`), + * - the standard cache-row persist (`persistStatusInsight`), + * - the cheap INPUT gate (`gateUnchangedStatusInput`) keyed on the latest + * reading fingerprint so a fresh assessment is produced ONLY when a new + * reading lands — an idle day re-stamps the cached text without an LLM + * round-trip. + * + * The return shape is byte-identical to the metric generator so + * `InsightStatusCard` consumes it unchanged. The cache scope is + * `biomarker:`, which the on-demand queue worker routes back here. + * + * Empty-data guard: a marker with no numeric readings returns an + * `insufficient` marker WITHOUT touching the provider chain. + */ +import { prisma } from "@/lib/db"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + summarizeSeries, +} from "@/lib/insights/status-shared"; +import { + gateUnchangedStatusInput, + readFreshStatusText, + resolveReadOnlyStatusMiss, + statusCacheAction, +} from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { annotate } from "@/lib/logging/context"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +export interface BiomarkerStatusResult { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + /** True when last-good text is served while a fresh generation runs. */ + revalidating?: boolean; + /** True when the marker has no numeric readings; no LLM was called. */ + insufficient?: boolean; +} + +/** The `biomarker:` cache/queue scope for one marker. */ +export function biomarkerStatusScope( + biomarkerId: string, +): `biomarker:${string}` { + return `biomarker:${biomarkerId}`; +} + +/** Cap on the recent reading rows folded into the prompt snapshot. */ +const SNAPSHOT_READING_CAP = 60; + +interface BiomarkerReading { + id: string; + value: number; + takenAt: Date; +} + +function insufficient(): BiomarkerStatusResult { + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; +} + +function getSystemPrompt(locale: SupportedLocale, markerName: string): string { + if (locale === "de") { + return [ + `Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`, + "Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.", + "Schreibe 2 bis 4 ruhige Sätze in klarer Alltagssprache: nenne den aktuellen Wert und seinen Bezug zum Referenzbereich, ordne die Tendenz über die letzten Messungen ein und gib höchstens einen sachlichen Hinweis.", + "Keine Panikmache, keine Marketing-Sprache, keine Befehle. Bei auffälligen Werten verweise neutral auf eine ärztliche Einordnung.", + 'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.', + ].join(" "); + } + return [ + `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, + "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", + "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", + "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", + 'Respond as a JSON object of the form { "summary": "…" } with no other text.', + ].join(" "); +} + +function getUserPrompt( + snapshotJson: string, + todayKey: string, + locale: SupportedLocale, +): string { + return locale === "de" + ? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}` + : `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`; +} + +/** + * Generate (or read from cache) the assessment for one biomarker. Mirrors + * the metric generator's contract: + * - cache hit (today, non-stub) → serve it, + * - `readOnly` miss → enqueue out-of-band + serve last-good, + * - no numeric readings → `insufficient`, no LLM call, + * - unchanged latest reading → re-stamp cached text, no LLM call, + * - otherwise → build snapshot, run completion, persist. + */ +export async function generateBiomarkerStatus(args: { + biomarkerId: string; + userId: string; + locale?: string | null; + force?: boolean; + /** Read-only navigation path — never blocks on the provider. */ + readOnly?: boolean; +}): Promise { + const marker = await prisma.biomarker.findFirst({ + where: { id: args.biomarkerId, userId: args.userId }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + }, + }); + if (!marker) { + // Unknown / cross-user marker — degrade gracefully rather than throwing. + return insufficient(); + } + + const locale = normalizeLocale(args.locale); + const force = args.force === true; + const readOnly = args.readOnly === true; + const scope = biomarkerStatusScope(marker.id); + const cacheAction = statusCacheAction(scope, locale); + const todayKey = toBerlinDayKey(new Date()); + + const cached = await readFreshStatusText({ + userId: args.userId, + cacheAction, + todayKey, + force, + }); + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; + } + + // Numeric readings only — a qualitative-only marker carries no range verdict + // or trend, so there is nothing to assess. Done BEFORE any enqueue so an + // empty marker never queues an LLM job nor blocks on the provider. + const rows = await prisma.labResult.findMany({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "desc" }, + take: SNAPSHOT_READING_CAP, + select: { id: true, value: true, takenAt: true }, + }); + if (rows.length === 0) { + annotate({ + action: { name: "insights.biomarker-status.insufficient" }, + meta: { biomarkerId: marker.id }, + }); + return insufficient(); + } + + const readings: BiomarkerReading[] = rows.map((r) => ({ + id: r.id, + value: r.value as number, + takenAt: r.takenAt, + })); + const latest = readings[0]; + const totalCount = await prisma.labResult.count({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + }); + + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId: args.userId, + metric: scope, + locale, + }); + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }; + } + + // INPUT gate: fingerprint the latest reading + count + bounds + locale + + // prompt version. When the fingerprint matches the cached assessment's + // stored `inputHash`, nothing the prompt could see has changed since the + // last reading, so re-stamp that text under today's day key and skip the + // LLM entirely. A new reading flips the latest id / value / count and + // forces a real regeneration — so the assessment regenerates ONLY on a new + // reading. + // + // The gate runs with `force: false` even on the worker's forced path: the + // outer `force` exists to bypass the SAME-DAY text cache (so a fresh day + // re-evaluates the fingerprint), not to burn an LLM call on an unchanged + // marker. The worker is re-enqueued whenever today's text cache misses, so + // letting the input gate run keeps an idle marker to one cheap re-stamp per + // day instead of a daily regeneration. + const inputHash = hashInsightSnapshot({ + scope, + locale, + promptVersion: PROMPT_VERSION, + count: totalCount, + bounds: { lower: marker.lowerBound, upper: marker.upperBound }, + latest: { + id: latest.id, + takenAt: latest.takenAt.toISOString(), + value: latest.value, + }, + }); + const unchanged = await gateUnchangedStatusInput({ + userId: args.userId, + cacheAction, + todayKey, + inputHash, + force: false, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + + // Ascending series (oldest → newest) for the trend summary the prompt reads. + const ascending = [...readings].reverse(); + const summary = summarizeSeries(ascending.map((r) => ({ value: r.value }))); + const latestStatus = classifyReferenceRange( + latest.value, + marker.lowerBound, + marker.upperBound, + ); + + const snapshot = { + locale, + promptVersion: PROMPT_VERSION, + generatedForDay: todayKey, + marker: { + name: marker.name, + unit: marker.unit, + referenceRange: + marker.lowerBound != null || marker.upperBound != null + ? { lower: marker.lowerBound, upper: marker.upperBound } + : null, + }, + dataCoverage: { + totalReadings: totalCount, + readingsInSnapshot: readings.length, + newestTakenAt: latest.takenAt.toISOString(), + oldestInSnapshot: ascending[0].takenAt.toISOString(), + }, + latest: { + value: latest.value, + takenAt: latest.takenAt.toISOString(), + rangeStatus: latestStatus, + }, + summary, + series: ascending.map((r) => ({ + day: toBerlinDayKey(r.takenAt), + value: r.value, + })), + }; + const snapshotJson = JSON.stringify(snapshot, null, 2); + + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + + const outcome = await runStatusCompletion({ + userId: args.userId, + cacheAction, + consentSurface: "insights", + systemPrompt: getSystemPrompt(locale, marker.name), + userPrompt: getUserPrompt(snapshotJson, todayKey, locale), + temperature: 0.45, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + + const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + if (!text) { + throw new Error(`Biomarker-status summary was empty for ${marker.id}`); + } + + const updatedAt = await persistStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + locale, + text, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + inputHash, + }); + + return { hasProvider: true, text, cached: false, updatedAt }; +} diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index cf7754813..2313f3409 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -67,6 +67,12 @@ interface CoachLaunchValue { open: boolean; /** Current prefill string (or null when the next open should start blank). */ prefill: string | null; + /** + * When true, the opened conversation auto-sends the prefill as its first + * turn (exactly once), so a card's "ask about this" hand-off lands the + * answer directly instead of only seeding the composer. Cleared on close. + */ + autoSend: boolean; /** * Scope the next/open conversation is narrowed to (or null for the * default all-source snapshot). Set by `askCoach(prefill, scope)`, or @@ -74,8 +80,16 @@ interface CoachLaunchValue { * explicit one. `` forwards it to the drawer. */ scope: CoachLaunchScope | null; - /** Open the drawer with an optional prefill + scope hint. */ - askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void; + /** + * Open the drawer with an optional prefill + scope hint. When `autoSend` + * is true the prefill is dispatched as the conversation's first turn + * automatically (used by the assessment hand-off). + */ + askCoach: ( + prefill?: string | null, + scope?: CoachLaunchScope, + autoSend?: boolean, + ) => void; /** * v1.21.0 (C4 H1) — register the metric surface the user is currently * looking at so the global FAB (which calls `askCoach()` with no args) @@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps { export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const [open, setOpen] = useState(false); const [prefill, setPrefill] = useState(null); + // Whether the next open auto-sends its prefill as the first turn. + const [autoSend, setAutoSend] = useState(false); // Scope the open conversation is narrowed to. `null` → default snapshot. const [scope, setScope] = useState(null); // Ambient scope + seed opener of the metric surface currently on screen. @@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const ambientPrefillRef = useRef(null); const askCoach = useCallback( - (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => { + ( + nextPrefill?: string | null, + nextScope?: CoachLaunchScope, + nextAutoSend?: boolean, + ) => { // v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight // card, metric-card affordance) wins; otherwise inherit the metric // page's ambient scope so the FAB opens contextual to where the user @@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { (usingAmbientScope ? ambientPrefillRef.current : null) ?? null, ); + // Auto-send only applies when an explicit prefill is given (a card + // hand-off), never for an ambient/blank open. + setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill)); setOpen(true); }, [], @@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const handleSetOpen = useCallback((next: boolean) => { setOpen(next); if (!next) { - // Drop the prefill + scope on close so the next open starts clean. + // Drop the prefill + scope + auto-send on close so the next open + // starts clean. setPrefill(null); setScope(null); + setAutoSend(false); } }, []); @@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { () => ({ open, prefill, + autoSend, scope, askCoach, registerScope, setOpen: handleSetOpen, }), - [open, prefill, scope, askCoach, registerScope, handleSetOpen], + [open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen], ); return ( diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 0811e46d2..906847142 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -67,7 +67,16 @@ export type InsightStatusScope = * always fills the field synchronously — this queue only warms the AI prose * that overrides it on the next read. */ - | `derived-score:${string}`; + | `derived-score:${string}` + /** + * Per-biomarker assessment scopes ride the same on-demand queue. Their + * scope id is `biomarker:`; the worker routes the prefix to the + * biomarker generator, which reads `LabResult` rows for the marker and + * applies its own empty-data + input-hash gates, so a job enqueued for a + * marker with no new reading costs only a cheap fingerprint, not an LLM + * call. + */ + | `biomarker:${string}`; export interface InsightStatusGeneratePayload { userId: string; diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 1ca8d2a28..4b96cd16d 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -35,6 +35,7 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; import { isMetricStatusId } from "@/lib/insights/metric-status-registry"; import { prisma } from "@/lib/db"; import { @@ -115,6 +116,27 @@ export async function runInsightStatusGenerate( return; } + // A `biomarker:` scope routes to the per-biomarker generator, which + // reads `LabResult` rows for the marker. Its own empty-data + input-hash + // gates keep a job for an unchanged marker cheap (no LLM call). + if (payload.metric.startsWith("biomarker:")) { + const biomarkerId = payload.metric.slice("biomarker:".length); + if (!biomarkerId) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generateBiomarkerStatus({ + biomarkerId, + userId: payload.userId, + locale: payload.locale, + force: true, + }); + return; + } + // v1.13.2 — a `derived-score:` scope warms the per-score assessment AI // prose. The route already filled the deterministic text synchronously; this // recomputes the derived value and persists the warmer prose so the next diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index a3826a351..59ef64879 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -9,6 +9,8 @@ import { insightsComprehensiveResponse, metricStatusQuery, metricStatusResponse, + biomarkerAssessmentQuery, + biomarkerAssessmentResponse, derivedMetricQuery, derivedMetricResponse, derivedBatchQuery, @@ -300,6 +302,31 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/biomarker-assessment": { + get: { + tags: ["Insights"], + summary: "Per-biomarker assessment", + description: + "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.", + requestParams: { + query: biomarkerAssessmentQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + biomarkerAssessmentResponse, + "BiomarkerAssessmentResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/derived": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 82971e74f..09f805707 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -473,6 +473,70 @@ export const insightStatusResponse = z "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// Per-biomarker assessment. The marker is identified by a user-scoped id +// (the generic metric-status route fixes its metric by a closed registry +// enum; biomarkers are user-defined, so the id is a free-form string). +export const biomarkerAssessmentQuery = z + .object({ + biomarkerId: z + .string() + .min(1) + .max(64) + .describe( + "User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "BiomarkerAssessmentQuery" }); + +export const biomarkerAssessmentResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.", + ), + }) + .meta({ + id: "BiomarkerAssessmentResponse", + description: + "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands.", + }); + // The medication-compliance route carries a richer envelope than the // other six: a `summary` narrative plus a per-medication `text` array, // instead of a single `text` field. diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 5ce80bdb2..22e306553 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -43,6 +43,16 @@ export const insightsKeys = { */ insightsMetricStatus: (metric: string, locale: string) => ["insights", "metric-status", metric, locale] as const, + /** + * Per-biomarker assessment for the lab-marker detail page. One generic + * route (`/api/insights/biomarker-assessment?biomarkerId=…`) backs every + * marker, so the key is parameterised by the marker id alongside the + * locale. Same read-only + stale-while-revalidate contract as + * `insightsMetricStatus`; the `["insights"]` prefix keeps it in the + * standard invalidation fan-out. + */ + insightsBiomarkerAssessment: (biomarkerId: string, locale: string) => + ["insights", "biomarker-assessment", biomarkerId, locale] as const, /** * v1.8.7.1 — mutationKey for the on-demand full-warm POST * (`/api/insights/pregenerate`). Lives in the factory so the bare-array diff --git a/tests/integration/biomarker-detail.test.ts b/tests/integration/biomarker-detail.test.ts new file mode 100644 index 000000000..318fc99cc --- /dev/null +++ b/tests/integration/biomarker-detail.test.ts @@ -0,0 +1,172 @@ +/** + * Integration suite for the biomarker resource route + * (`/api/biomarkers/{id}`) against a real Postgres. + * + * Covers the two detail-page mutations: + * - PUT adjusts the target range (and rejects an inverted window), + * - DELETE drops the marker together with its readings in one + * userId-narrowed transaction, leaving another user's marker untouched. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-biomarker-detail-test"; +const OTHER_USER_ID = "user-biomarker-other-test"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { id: USER_ID, username: "bm-detail", email: "bm-detail@example.test" }, + { + id: OTHER_USER_ID, + username: "bm-other", + email: "bm-other@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(id: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function delReq(id: string): NextRequest { + return new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "DELETE", + }); +} + +async function seedMarkerWithReadings(userId: string, name: string) { + const prisma = getPrismaClient(); + const marker = await prisma.biomarker.create({ + data: { userId, name, unit: "mg/dL", lowerBound: 0, upperBound: 100 }, + }); + await prisma.labResult.createMany({ + data: [ + { + userId, + biomarkerId: marker.id, + analyte: name, + unit: "mg/dL", + value: 95, + takenAt: new Date("2026-06-20T08:00:00.000Z"), + }, + { + userId, + biomarkerId: marker.id, + analyte: name, + unit: "mg/dL", + value: 88, + takenAt: new Date("2026-05-20T08:00:00.000Z"), + }, + ], + }); + return marker; +} + +describe("PUT /api/biomarkers/{id} — target range (real Postgres)", () => { + it("persists an updated reference window", async () => { + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "LDL"); + + const res = await PUT( + putReq(marker.id, { lowerBound: 10, upperBound: 50 }), + params(marker.id), + ); + expect(res.status).toBe(200); + + const row = await getPrismaClient().biomarker.findUnique({ + where: { id: marker.id }, + }); + expect(row?.lowerBound).toBe(10); + expect(row?.upperBound).toBe(50); + }); + + it("422s an inverted target range and leaves the bounds untouched", async () => { + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "HbA1c"); + + const res = await PUT( + putReq(marker.id, { lowerBound: 80, upperBound: 20 }), + params(marker.id), + ); + expect(res.status).toBe(422); + + const row = await getPrismaClient().biomarker.findUnique({ + where: { id: marker.id }, + }); + expect(row?.lowerBound).toBe(0); + expect(row?.upperBound).toBe(100); + }); +}); + +describe("DELETE /api/biomarkers/{id} (real Postgres)", () => { + it("drops the marker together with its readings", async () => { + const { DELETE } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "Ferritin"); + + const res = await DELETE(delReq(marker.id), params(marker.id)); + expect(res.status).toBe(200); + + const prisma = getPrismaClient(); + expect(await prisma.biomarker.count({ where: { id: marker.id } })).toBe(0); + expect( + await prisma.labResult.count({ where: { biomarkerId: marker.id } }), + ).toBe(0); + }); + + it("404s another user's marker and leaves it (and its readings) intact", async () => { + const { DELETE } = await import("@/app/api/biomarkers/[id]/route"); + const foreign = await seedMarkerWithReadings(OTHER_USER_ID, "TSH"); + + const res = await DELETE(delReq(foreign.id), params(foreign.id)); + expect(res.status).toBe(404); + + const prisma = getPrismaClient(); + expect(await prisma.biomarker.count({ where: { id: foreign.id } })).toBe(1); + expect( + await prisma.labResult.count({ where: { biomarkerId: foreign.id } }), + ).toBe(2); + }); +});