feat(insights): make each person-defined symptom its own correlation channel

Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, 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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:14:08 +02:00
1 parent d6571c4e9c
commit 9d9e1f153c
15 files changed
+799 -4

No files matched your search

@@ -91,6 +91,7 @@ describe("T1 — only the assembler fetches discovery channels", () => {
"fetchSymptomSeries",
"fetchEnvironmentSeries",
"fetchCustomMetricBehaviourSeries",
"fetchSymptomEventSeries",
"fetchMoodWindowSeries",
"fetchMoodFactorWindowSeries",
"fetchMeasurementDailySeriesTiered",
@@ -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<typeof vi.fn>
).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<Response>;
@@ -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,
},
});
@@ -39,6 +39,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
@@ -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);
@@ -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 () => []) },
},
}));
@@ -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"]);
});
});
@@ -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:<id>` 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:<definitionId>` 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<NamedSeries[]> {
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);
@@ -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:<definitionId>` 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<string, number>();
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,
};
}
@@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
+11
View File
@@ -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,
},
};
}
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
@@ -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",
);
});
});
+7 -4
View File
@@ -274,10 +274,13 @@ export function surfaceModulesOfKind(
/**
* The surface id of a correlation channel. Rated mood factors carry their own
* `FACTOR:<key>` 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:<id>`) 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:<id>`, 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}`;
}
+578
View File
@@ -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:<id>` 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<Record<string, string>> },
) => Promise<Response>;
async function call(
handler: unknown,
method: string,
path: string,
body?: unknown,
params: Record<string, string> = {},
): Promise<Response> {
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<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
interface Envelope<T> {
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<string> {
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<Envelope<{ id: string }>>(response)).data.id;
}
async function logEvent(body: Record<string, unknown>): Promise<Response> {
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<Envelope<null>>(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<Envelope<{ eventsDeleted: number }>>(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<Envelope<null>>(
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<Envelope<{ id: string; note: string }>>(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<Envelope<{ events: Array<{ id: string }> }>>(
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<Envelope<{ episodeId: string }>>(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<Envelope<null>>(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<Envelope<null>>(response)).meta?.errorCode).toBe(
"illness.disabled",
);
});
});
describe("the SYMPTOM:<id> 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 },
]);
});
});