From 9d9e1f153ca2a080e472b2150906458f8b9279a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:14:08 +0200 Subject: [PATCH] feat(insights): make each person-defined symptom its own correlation channel Every active symptom definition enters pattern discovery as an outcome channel SYMPTOM:, labelled with its decrypted name (the data label the interpretation already prefers over the key). The daily value is the day's highest intensity in the person's zone; days without an occurrence count as 0 only between the first and the last logged day. At most eight, the active-definition cap. The SYMPTOM: prefix resolves to the illness module in the surface map, so the family leaves the matrix with the module, and the discovery guard lists the new fetcher. --- src/__tests__/discovery-matrix-guard.test.ts | 1 + .../correlations/__tests__/route.test.ts | 2 + src/app/api/insights/correlations/route.ts | 1 + .../coach/__tests__/correlations-read.test.ts | 1 + .../discovery-matrix-modules.test.ts | 5 + .../metric-correlation-context.test.ts | 1 + .../__tests__/symptom-event-series.test.ts | 72 +++ .../insights/correlation-channel-series.ts | 60 ++ .../insights/correlation-series-builders.ts | 51 ++ .../__tests__/coach-read-locale.test.ts | 1 + src/lib/insights/discovery-matrix.ts | 11 + .../__tests__/period-narrative-db.test.ts | 1 + .../__tests__/surface-module-registry.test.ts | 7 + src/lib/modules/surface.ts | 11 +- tests/integration/symptoms.test.ts | 578 ++++++++++++++++++ 15 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/lib/insights/__tests__/symptom-event-series.test.ts create mode 100644 tests/integration/symptoms.test.ts diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index ca4d3c9f2..128b5e8ac 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -91,6 +91,7 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchSymptomSeries", "fetchEnvironmentSeries", "fetchCustomMetricBehaviourSeries", + "fetchSymptomEventSeries", "fetchMoodWindowSeries", "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 47fa6a4a2..42413f24f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({ // channel produces a point. environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -153,6 +154,7 @@ beforeEach(() => { prisma.environmentContext.findMany as ReturnType ).mockResolvedValue([]); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index f93c5262f..c94021409 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -236,6 +236,7 @@ async function buildCorrelationsResponse( // available for the scan. environment_days: diagnostics.environmentDays, custom_metric_channels: diagnostics.customMetricChannels, + symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, }, }); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index f020b98c1..088f0677e 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -39,6 +39,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index e13d77393..930b52b27 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -42,6 +42,9 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({ fetchCustomMetricBehaviourSeries: vi.fn(async () => [ { key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts }, ]), + fetchSymptomEventSeries: vi.fn(async () => [ + { key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts }, + ]), })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(), @@ -77,6 +80,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { "SYMPTOM_SEVERITY", "ENV_TEMP_MEAN", "CUSTOM_METRIC:abc", + "SYMPTOM:def1", "WEIGHT", ]) { expect(keys).toContain(key); @@ -103,6 +107,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { "BLOOD_GLUCOSE", "MEDICATION_COMPLIANCE", "SYMPTOM_SEVERITY", + "SYMPTOM:def1", "ENV_TEMP_MEAN", ]) { expect(keys, gone).not.toContain(gone); diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index f77052076..07927a927 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/db", () => ({ illnessDayLog: { findMany: vi.fn() }, environmentContext: { findMany: vi.fn() }, customMetric: { findMany: vi.fn() }, + symptomDefinition: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/insights/__tests__/symptom-event-series.test.ts b/src/lib/insights/__tests__/symptom-event-series.test.ts new file mode 100644 index 000000000..17c7b9f3a --- /dev/null +++ b/src/lib/insights/__tests__/symptom-event-series.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { buildSymptomEventDailySeries } from "@/lib/insights/correlation-series-builders"; +import { maskSeriesByModules } from "@/lib/insights/discovery-matrix"; + +const at = (iso: string) => new Date(iso); + +describe("buildSymptomEventDailySeries", () => { + it("takes the day's highest intensity and zero-fills only between the first and last day", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "Europe/Berlin", + events: [ + { at: at("2026-07-01T07:00:00.000Z"), intensity: 3 }, + { at: at("2026-07-01T15:00:00.000Z"), intensity: 7 }, + { at: at("2026-07-04T10:00:00.000Z"), intensity: 2 }, + ], + }); + expect(series).toEqual({ + key: "SYMPTOM:def1", + label: "Aura", + role: "outcome", + points: [ + { day: "2026-07-01", value: 7 }, + { day: "2026-07-02", value: 0 }, + { day: "2026-07-03", value: 0 }, + { day: "2026-07-04", value: 2 }, + ], + }); + }); + + it("keys a late-evening occurrence to the person's own day", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "Europe/Berlin", + // 23:30 UTC on the 1st is 01:30 on the 2nd in Berlin (summer time). + events: [{ at: at("2026-07-01T23:30:00.000Z"), intensity: 5 }], + }); + expect(series.points).toEqual([{ day: "2026-07-02", value: 5 }]); + }); + + it("is empty with no occurrences, so the channel drops out", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "UTC", + events: [], + }); + expect(series.points).toEqual([]); + }); +}); + +describe("the SYMPTOM: channel family follows the illness module", () => { + const series = [ + { key: "SYMPTOM:def1", role: "outcome" as const, points: [] }, + { key: "SLEEP_DURATION", role: "behaviour" as const, points: [] }, + ]; + + it("is masked out when the illness module is off", () => { + expect( + maskSeriesByModules(series, { illness: false }).map((s) => s.key), + ).toEqual(["SLEEP_DURATION"]); + }); + + it("stays when the module is on", () => { + expect( + maskSeriesByModules(series, { illness: true }).map((s) => s.key), + ).toEqual(["SYMPTOM:def1", "SLEEP_DURATION"]); + }); +}); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index aaed68cfa..b0278790d 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -39,6 +39,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import { buildComplianceDailySeries, + buildSymptomEventDailySeries, buildSymptomSeverityDailySeries, type SymptomDayLogRow, type SymptomEpisodeSpan, @@ -61,6 +62,11 @@ import type { } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { dateOnlyKey } from "@/lib/tz/date-only"; +import { decryptSymptomText } from "@/lib/symptoms/server"; +import { + MAX_ACTIVE_SYMPTOM_DEFINITIONS, + SYMPTOM_CHANNEL_PREFIX, +} from "@/lib/symptoms/shared"; /** * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. @@ -315,6 +321,60 @@ export async function fetchCustomMetricBehaviourSeries( })); } +/** + * Ceiling on `SYMPTOM:` channels. The definition cap already holds the + * active set at eight; restated here so the matrix bound does not depend on a + * route check staying where it is. + */ +export const MAX_SYMPTOM_CORRELATION_CHANNELS = MAX_ACTIVE_SYMPTOM_DEFINITIONS; + +/** + * v1.40 — one OUTCOME series per active person-defined symptom, keyed + * `SYMPTOM:` and labelled with the decrypted name (the data + * label `interpret()` prefers over the key). Daily value = that day's highest + * intensity; see `buildSymptomEventDailySeries` for the zero-fill rule. Hidden + * definitions are not channels. A symptom whose name cannot be decrypted is + * left out rather than shown under its database id. + */ +export async function fetchSymptomEventSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const definitions = await prisma.symptomDefinition.findMany({ + where: { userId, isActive: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], + take: MAX_SYMPTOM_CORRELATION_CHANNELS, + select: { + id: true, + labelEncrypted: true, + events: { + where: { occurredAt: { gte: since } }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: 5000, + select: { occurredAt: true, intensity: true }, + }, + }, + }); + const series: NamedSeries[] = []; + for (const definition of definitions) { + const label = decryptSymptomText(definition.labelEncrypted, "label"); + if (label === null) continue; + series.push( + buildSymptomEventDailySeries({ + key: `${SYMPTOM_CHANNEL_PREFIX}${definition.id}`, + label, + events: definition.events.map((event) => ({ + at: event.occurredAt, + intensity: event.intensity, + })), + tz, + }), + ); + } + return series; +} + /** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { const { year, month, day } = wallClockInTz(at, tz); diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index b387fa643..7da33628e 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -35,6 +35,7 @@ * mirroring how the engine itself is pure over `NamedSeries[]`. */ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { shiftDateKey } from "@/lib/tz/format"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { MEDICATION_COMPLIANCE_CHANNEL_KEY, @@ -191,3 +192,53 @@ export function buildSymptomSeverityDailySeries(args: { return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points }; } + +/** One occurrence of a person-defined symptom, as the builder reads it. */ +export interface SymptomEventRow { + at: Date; + intensity: number; +} + +/** + * Build one `SYMPTOM:` outcome series from that symptom's + * occurrences (v1.40). + * + * The daily value is the day's HIGHEST intensity (0-10) in the person's zone: + * two attacks in a day are as bad as the worse one. Days with no occurrence + * are 0, but only between the first and the last logged day: inside that span + * the person was tracking the symptom and its absence is a real zero, outside + * it nothing says whether they were tracking at all. One logged day yields a + * one-point series, which the n >= 20 floor then drops. Pure. + */ +export function buildSymptomEventDailySeries(args: { + key: string; + label: string | undefined; + events: SymptomEventRow[]; + tz: string; +}): NamedSeries { + const { key, label, events, tz } = args; + const byDay = new Map(); + for (const event of events) { + if (!Number.isFinite(event.intensity)) continue; + const day = dayKey(event.at, tz); + const prev = byDay.get(day); + byDay.set( + day, + prev == null ? event.intensity : Math.max(prev, event.intensity), + ); + } + const keys = [...byDay.keys()].sort(); + const points: DailySeriesPoint[] = []; + if (keys.length > 0) { + const last = keys[keys.length - 1]; + for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) { + points.push({ day, value: byDay.get(day) ?? 0 }); + } + } + return { + key, + ...(label !== undefined ? { label } : {}), + role: "outcome", + points, + }; +} diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index 1c2208411..ebaa47e75 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index 7e86c64ea..56a207a9c 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -48,6 +48,7 @@ import { buildMeasurementDailySeries, fetchComplianceSeries, fetchCustomMetricBehaviourSeries, + fetchSymptomEventSeries, fetchEnvironmentSeries, fetchMeasurementDailySeriesTiered, fetchMeasurementWindowSeries, @@ -154,6 +155,8 @@ export interface DiscoveryMatrixDiagnostics { customMetricDays: number; /** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */ moodFactorChannels: number; + /** `SYMPTOM:*` channels read (before the module mask). */ + symptomEventChannels: number; } export interface DiscoveryMatrix { @@ -294,6 +297,7 @@ export async function assembleDiscoveryMatrix( symptomSeries, environmentSeries, customMetricSeries, + symptomEventSeries, ] = await Promise.all([ readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), readMood(userId, tz, since, opts.includeMoodFactors === true), @@ -301,6 +305,7 @@ export async function assembleDiscoveryMatrix( fetchSymptomSeries(userId, tz, since), fetchEnvironmentSeries(userId, since), fetchCustomMetricBehaviourSeries(userId, tz, since), + fetchSymptomEventSeries(userId, tz, since), ]); const { factorSeries } = mood; @@ -331,6 +336,11 @@ export async function assembleDiscoveryMatrix( // (D → D+1) against every outcome above, under the unchanged n ≥ 20 / FDR / // effect-size gates, so a thin weather or custom series degrades to absent. series.push(...environmentSeries, ...customMetricSeries); + // The person's own symptoms are OUTCOMES: "short sleep, then an aura the + // next day" is the question they log them to answer. Each is its own + // channel and its own family, capped at eight; the illness module owns them + // through the `SYMPTOM:` prefix rule in the surface map. + series.push(...symptomEventSeries); // A factor is plausibly both a lag source ("rated work today → next-day // sleep") and a lag target ("steps today → next-day rated energy"), so it // enters as both roles. The engine skips self-pairs — every factor shares @@ -371,6 +381,7 @@ export async function assembleDiscoveryMatrix( 0, ), moodFactorChannels: factorSeries.size, + symptomEventChannels: symptomEventSeries.length, }, }; } diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index 29462480f..a2240617d 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index e96b1325d..a48be16df 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -66,9 +66,11 @@ import { TYPE_TO_SUB_PAGE_SLUG, } from "@/lib/insights/sub-page-metric"; import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select"; +import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared"; import { SURFACE_KINDS, SURFACE_MODULE, + correlationChannelSurfaceId, surfaceModule, surfaceModulesOfKind, type SurfaceKind, @@ -167,6 +169,11 @@ describe("every surface id names a surface that exists", () => { // `correlationChannelSurfaceId` folds rated factors onto mood by this // prefix; it has to be the prefix the engine actually writes. expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:"); + // Person-defined symptoms fold onto the illness symptom channel by theirs. + expect(SYMPTOM_CHANNEL_PREFIX).toBe("SYMPTOM:"); + expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe( + "correlation:SYMPTOM_SEVERITY", + ); }); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 4e39ab30f..6fdb69f58 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -274,10 +274,13 @@ export function surfaceModulesOfKind( /** * The surface id of a correlation channel. Rated mood factors carry their own * `FACTOR:` channel names but are mood entries all the same, so they - * resolve to the mood channel. + * resolve to the mood channel; person-defined symptoms (`SYMPTOM:`) resolve + * to the illness symptom channel the same way. */ export function correlationChannelSurfaceId(channelKey: string): string { - return channelKey.startsWith("FACTOR:") - ? "correlation:MOOD" - : `correlation:${channelKey}`; + if (channelKey.startsWith("FACTOR:")) return "correlation:MOOD"; + // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness + // module, like the symptom-burden channel it sits beside. + if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; + return `correlation:${channelKey}`; } diff --git a/tests/integration/symptoms.test.ts b/tests/integration/symptoms.test.ts new file mode 100644 index 000000000..330d0dfbe --- /dev/null +++ b/tests/integration/symptoms.test.ts @@ -0,0 +1,578 @@ +/** + * Person-defined symptoms against real Postgres and through the real routes + * (v1.40, migration 0369). + * + * What only a real database can answer: + * + * - the two tables exist, the label and the note are ciphertext on disk, and + * the intensity CHECK holds; + * - the eight-definition cap counts active definitions only, and showing a + * hidden one again re-checks it; + * - an occurrence can be filed against the record's own OPEN episode and + * against nothing else: not a resolved one, not another account's; + * - each active definition reaches the discovery matrix as its own + * `SYMPTOM:` channel, labelled with its name, and leaves it when the + * illness module is off; + * - a portable export carries the names readable and restores them under + * the instance's key, dropping (and naming) a link to an episode the file + * does not carry. + * + * Mutation checks (each run, each seen red): + * - drop `resolvedAt: null` from `isLinkableEpisode` → "refuses a resolved + * episode" goes red (the occurrence is linked to a closed episode); + * - drop `userId` from the same lookup → "refuses another account's episode" + * goes red; + * - remove the cap check in the definitions POST → "holds eight active + * definitions" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { buildSymptomsBackupSection } from "@/lib/export/symptoms-backup"; +import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +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), +})); + +const OWNER_ID = "symptoms-owner"; +const OTHER_ID = "symptoms-other"; + +type Handler = ( + req: NextRequest, + ctx: { params: Promise> }, +) => Promise; + +async function call( + handler: unknown, + method: string, + path: string, + body?: unknown, + params: Record = {}, +): Promise { + const request = new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return (handler as Handler)(request, { params: Promise.resolve(params) }); +} + +async function json(response: Response): Promise { + return (await response.json()) as T; +} + +interface Envelope { + data: T; + error: string | null; + meta?: { errorCode?: string }; +} + +async function seedUsers() { + const prisma = getPrismaClient(); + for (const id of [OWNER_ID, OTHER_ID]) { + await prisma.user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "Europe/Berlin", + locale: "en", + }, + }); + } +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 86_400_000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function defineSymptom(label: string): Promise { + const { POST } = await import("@/app/api/symptoms/definitions/route"); + const response = await call(POST, "POST", "/api/symptoms/definitions", { + label, + icon: "Zap", + }); + expect(response.status, label).toBe(201); + return (await json>(response)).data.id; +} + +async function logEvent(body: Record): Promise { + const { POST } = await import("@/app/api/symptoms/events/route"); + return call(POST, "POST", "/api/symptoms/events", body); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedUsers(); + await signIn(OWNER_ID); +}); + +describe("symptom definitions", () => { + it("stores the name as ciphertext and reads it back with its summary", async () => { + const id = await defineSymptom("Aura"); + const row = await getPrismaClient().symptomDefinition.findUniqueOrThrow({ + where: { id }, + }); + expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain( + "Aura", + ); + expect(decryptFromBytes(row.labelEncrypted)).toBe("Aura"); + + await logEvent({ definitionId: id, intensity: 3 }); + await logEvent({ definitionId: id, intensity: 7 }); + + const { GET } = await import("@/app/api/symptoms/definitions/route"); + const listed = await json< + Envelope<{ + definitions: Array<{ + id: string; + label: string; + recent: { count30d: number; maxIntensity30d: number | null }; + }>; + limit: number; + }> + >(await call(GET, "GET", "/api/symptoms/definitions")); + expect(listed.data.limit).toBe(8); + expect(listed.data.definitions).toMatchObject([ + { id, label: "Aura", recent: { count30d: 2, maxIntensity30d: 7 } }, + ]); + }); + + it("holds eight active definitions, and a hidden one frees its slot", async () => { + const ids: string[] = []; + for (let i = 0; i < 8; i += 1) ids.push(await defineSymptom(`S${i}`)); + + const { POST } = await import("@/app/api/symptoms/definitions/route"); + const ninth = await call(POST, "POST", "/api/symptoms/definitions", { + label: "Ninth", + }); + expect(ninth.status).toBe(422); + expect((await json>(ninth)).meta?.errorCode).toBe( + "symptoms.definition.limitReached", + ); + + const { DELETE, PATCH } = + await import("@/app/api/symptoms/definitions/[id]/route"); + const hidden = await call( + DELETE, + "DELETE", + `/api/symptoms/definitions/${ids[0]}`, + undefined, + { id: ids[0] }, + ); + expect(hidden.status).toBe(200); + expect(await defineSymptom("Ninth")).toBeTruthy(); + + // Showing the hidden one again would make nine. + const reshown = await call( + PATCH, + "PATCH", + `/api/symptoms/definitions/${ids[0]}`, + { isActive: true }, + { id: ids[0] }, + ); + expect(reshown.status).toBe(422); + expect( + ( + await getPrismaClient().symptomDefinition.findUniqueOrThrow({ + where: { id: ids[0] }, + }) + ).isActive, + ).toBe(false); + }); + + it("purges a definition with every occurrence of it", async () => { + const id = await defineSymptom("Aura"); + await logEvent({ definitionId: id, intensity: 4 }); + const { DELETE } = + await import("@/app/api/symptoms/definitions/[id]/route"); + const response = await call( + DELETE, + "DELETE", + `/api/symptoms/definitions/${id}?purge=true`, + undefined, + { id }, + ); + expect(response.status).toBe(200); + expect( + (await json>(response)).data + .eventsDeleted, + ).toBe(1); + expect( + await getPrismaClient().symptomEvent.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(0); + }); + + it("is a 404 for another account's definition", async () => { + const id = await defineSymptom("Aura"); + await signIn(OTHER_ID); + const { PATCH } = await import("@/app/api/symptoms/definitions/[id]/route"); + const response = await call( + PATCH, + "PATCH", + `/api/symptoms/definitions/${id}`, + { label: "Mine now" }, + { id }, + ); + expect(response.status).toBe(404); + expect( + ( + await json>( + await logEvent({ definitionId: id, intensity: 2 }), + ) + ).meta?.errorCode, + ).toBe("symptoms.definition.notFound"); + }); +}); + +describe("symptom occurrences", () => { + it("logs, lists, edits and deletes one, with the note encrypted", async () => { + const id = await defineSymptom("Headache"); + const created = await logEvent({ + definitionId: id, + intensity: 8, + occurredAt: "2026-09-01T09:30:00.000Z", + note: "behind the left eye", + }); + expect(created.status).toBe(201); + const event = (await json>(created)) + .data; + expect(event.note).toBe("behind the left eye"); + const row = await getPrismaClient().symptomEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(decryptFromBytes(row.noteEncrypted!)).toBe("behind the left eye"); + + const { GET } = await import("@/app/api/symptoms/events/route"); + const listed = await json }>>( + await call(GET, "GET", `/api/symptoms/events?definitionId=${id}`), + ); + expect(listed.data.events.map((e) => e.id)).toEqual([event.id]); + + const { PATCH, DELETE } = + await import("@/app/api/symptoms/events/[id]/route"); + const patched = await call( + PATCH, + "PATCH", + `/api/symptoms/events/${event.id}`, + { intensity: 5, note: null }, + { id: event.id }, + ); + expect(patched.status).toBe(200); + const after = await getPrismaClient().symptomEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(after.intensity).toBe(5); + expect(after.noteEncrypted).toBeNull(); + + const deleted = await call( + DELETE, + "DELETE", + `/api/symptoms/events/${event.id}`, + undefined, + { id: event.id }, + ); + expect(deleted.status).toBe(200); + expect( + await getPrismaClient().symptomEvent.count({ where: { id: event.id } }), + ).toBe(0); + }); + + it("refuses an intensity outside 0-10 and a time in the future", async () => { + const id = await defineSymptom("Aura"); + expect((await logEvent({ definitionId: id, intensity: 11 })).status).toBe( + 422, + ); + expect( + ( + await logEvent({ + definitionId: id, + intensity: 3, + occurredAt: new Date(Date.now() + 3_600_000).toISOString(), + }) + ).status, + ).toBe(422); + }); + + it("holds the 0-10 bound in the database too", async () => { + const id = await defineSymptom("Aura"); + await expect( + getPrismaClient().symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: id, + occurredAt: new Date(), + intensity: 12, + }, + }), + ).rejects.toThrow(); + }); + + it("links an occurrence to the record's own open episode", async () => { + const id = await defineSymptom("Aura"); + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: episode.id, + }); + expect(response.status).toBe(201); + expect( + (await json>(response)).data.episodeId, + ).toBe(episode.id); + }); + + it("refuses a resolved episode, and nothing is written", async () => { + const id = await defineSymptom("Aura"); + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-08-01T00:00:00.000Z"), + resolvedAt: new Date("2026-08-08T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: episode.id, + }); + expect(response.status).toBe(422); + expect((await json>(response)).meta?.errorCode).toBe( + "symptoms.episode.notOpen", + ); + expect( + await getPrismaClient().symptomEvent.count({ + where: { episodeId: episode.id }, + }), + ).toBe(0); + }); + + it("refuses another account's episode, and nothing is written", async () => { + const id = await defineSymptom("Aura"); + const foreign = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OTHER_ID, + label: "Flu", + type: "INFECTION", + onsetAt: new Date("2026-09-01T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: foreign.id, + }); + expect(response.status).toBe(422); + expect( + await getPrismaClient().symptomEvent.count({ + where: { episodeId: foreign.id }, + }), + ).toBe(0); + }); + + it("answers illness.disabled with the module off", async () => { + await getPrismaClient().user.update({ + where: { id: OWNER_ID }, + data: { modulePreferencesJson: { illness: false } }, + }); + const { GET } = await import("@/app/api/symptoms/definitions/route"); + const response = await call(GET, "GET", "/api/symptoms/definitions"); + expect(response.status).toBe(403); + expect((await json>(response)).meta?.errorCode).toBe( + "illness.disabled", + ); + }); +}); + +describe("the SYMPTOM: correlation channel", () => { + it("reaches the matrix labelled with its name, and leaves it with the module off", async () => { + const prisma = getPrismaClient(); + const definition = await prisma.symptomDefinition.create({ + data: { userId: OWNER_ID, labelEncrypted: encryptToBytes("Aura") }, + }); + const hidden = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Old"), + isActive: false, + }, + }); + for (const [id, at, intensity] of [ + [definition.id, "2026-09-01T08:00:00.000Z", 3], + [definition.id, "2026-09-01T18:00:00.000Z", 6], + [definition.id, "2026-09-03T08:00:00.000Z", 2], + [hidden.id, "2026-09-02T08:00:00.000Z", 9], + ] as const) { + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: id, + occurredAt: new Date(at), + intensity, + }, + }); + } + + const options = { + tz: "Europe/Berlin", + since: new Date("2026-08-01T00:00:00.000Z"), + fetchMode: "raw" as const, + }; + const on = await assembleDiscoveryMatrix(OWNER_ID, { + ...options, + modules: {}, + }); + const channels = on.series.filter((s) => s.key.startsWith("SYMPTOM:")); + expect(channels).toEqual([ + { + key: `SYMPTOM:${definition.id}`, + label: "Aura", + role: "outcome", + points: [ + { day: "2026-09-01", value: 6 }, + { day: "2026-09-02", value: 0 }, + { day: "2026-09-03", value: 2 }, + ], + }, + ]); + + const off = await assembleDiscoveryMatrix(OWNER_ID, { + ...options, + modules: { illness: false }, + }); + expect(off.series.some((s) => s.key.startsWith("SYMPTOM:"))).toBe(false); + }); +}); + +describe("the portable backup", () => { + it("carries the names readable and restores them, dropping a link to an episode the file lacks", async () => { + const prisma = getPrismaClient(); + const kept = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const gone = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-07-01T00:00:00.000Z"), + }, + }); + const definition = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Aura"), + icon: "Zap", + }, + }); + for (const [episodeId, intensity] of [ + [kept.id, 4], + [gone.id, 6], + [null, 2], + ] as const) { + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: definition.id, + occurredAt: new Date(`2026-07-0${intensity}T08:00:00.000Z`), + intensity, + episodeId, + noteEncrypted: intensity === 2 ? encryptToBytes("mild") : null, + }, + }); + } + + const section = await buildSymptomsBackupSection(prisma, OWNER_ID); + const text = JSON.stringify(section); + expect(text).toContain('"label":"Aura"'); + expect(text).toContain('"note":"mild"'); + expect(text).not.toContain("labelEncrypted"); + + // The restore puts back only the kept episode. + const skips: RestoreSkipLog = []; + await prisma.$transaction((tx) => + restoreSymptomsData( + tx, + OWNER_ID, + JSON.parse(text), + new Set([kept.id]), + skips, + ), + ); + + const restored = await prisma.symptomDefinition.findMany({ + where: { userId: OWNER_ID }, + include: { events: { orderBy: { intensity: "asc" } } }, + }); + expect(restored).toHaveLength(1); + expect(decryptFromBytes(restored[0].labelEncrypted)).toBe("Aura"); + expect( + restored[0].events.map((e) => ({ + intensity: e.intensity, + episodeId: e.episodeId, + note: e.noteEncrypted ? decryptFromBytes(e.noteEncrypted) : null, + })), + ).toEqual([ + { intensity: 2, episodeId: null, note: "mild" }, + { intensity: 4, episodeId: kept.id, note: null }, + { intensity: 6, episodeId: null, note: null }, + ]); + expect(skips).toEqual([ + { catalogue: "symptomEpisodeReference", key: gone.id, links: 1 }, + ]); + }); +});