feat(illness): add the condition-journal MVP behind a born-gated module

Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.

- Register `illness` as a born-gated (opt-in / default-off) module. The
  gate resolver flips the disabled-allowlist default to off for born-gated
  keys, so the surface stays dark until the account opts in from the
  Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
  module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
  delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
  route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
  userId from auth, builds data field-by-field, and gates the module.
  Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
  functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
  via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
  the day-log upsert, the gate enforcement, and the born-gated resolver.

The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:09:35 +02:00
1 parent bee7295b19
commit b079431a8d
34 files changed
+2799 -6

No files matched your search

@@ -0,0 +1,158 @@
/**
* v1.18.1 — illness day-log upsert helper.
*
* Asserts the insert-vs-update split, the partial-merge contract (an
* omitted field never nulls a stored value; an explicit null clears), and
* the symptom-link reconciliation (resolve catalog keys, drop unknowns,
* carry the 0–3 severity).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
illnessSymptom: { findMany: vi.fn() },
illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
illnessDayLog: { findUnique: vi.fn(), upsert: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
decryptFromBytes: (b: Uint8Array) =>
Buffer.from(b).toString("utf8").replace(/^enc:/, ""),
}));
import {
resolveIllnessSymptomIds,
replaceIllnessSymptomLinks,
upsertIllnessDayLog,
} from "@/lib/illness/day-log-write";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
beforeEach(() => {
vi.clearAllMocks();
db.illnessDayLog.upsert.mockResolvedValue({ id: "dl-1" });
});
describe("resolveIllnessSymptomIds", () => {
it("queries the active catalog and drops unknown keys", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
]);
const out = await resolveIllnessSymptomIds(["cough", "not_a_symptom"]);
expect(out).toEqual([{ key: "cough", id: "is_cough" }]);
const where = db.illnessSymptom.findMany.mock.calls[0][0].where;
expect(where.isActive).toBe(true);
expect(where.key.in).toEqual(["cough", "not_a_symptom"]);
});
it("short-circuits on an empty key set", async () => {
const out = await resolveIllnessSymptomIds([]);
expect(out).toEqual([]);
expect(db.illnessSymptom.findMany).not.toHaveBeenCalled();
});
});
describe("replaceIllnessSymptomLinks", () => {
it("clears then writes the links with their severity", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
{ id: "is_fatigue", key: "fatigue" },
]);
await replaceIllnessSymptomLinks("dl-1", [
{ key: "cough", severity: 3 },
{ key: "fatigue" },
]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalledWith({
where: { dayLogId: "dl-1" },
});
expect(db.illnessSymptomLink.createMany).toHaveBeenCalledWith({
data: [
{ dayLogId: "dl-1", symptomId: "is_cough", severity: 3 },
{ dayLogId: "dl-1", symptomId: "is_fatigue", severity: null },
],
skipDuplicates: true,
});
});
it("does not call createMany when nothing resolves", async () => {
db.illnessSymptom.findMany.mockResolvedValue([]);
await replaceIllnessSymptomLinks("dl-1", [{ key: "unknown" }]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalled();
expect(db.illnessSymptomLink.createMany).not.toHaveBeenCalled();
});
});
describe("upsertIllnessDayLog", () => {
const base: IllnessDayLogInput = {
date: "2026-06-16",
functionalImpact: 2,
feverC: 38.4,
};
it("reports inserted (existed=false) on a fresh row", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
const result = await upsertIllnessDayLog("u1", "ep1", base, "Europe/Berlin");
expect(result.existed).toBe(false);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.where).toEqual({
episodeId_date: { episodeId: "ep1", date: "2026-06-16" },
});
expect(args.create.functionalImpact).toBe(2);
expect(args.create.feverC).toBe(38.4);
});
it("reports updated (existed=true) on a re-post", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 1,
feverC: null,
noteEncrypted: null,
});
const result = await upsertIllnessDayLog("u1", "ep1", base, null);
expect(result.existed).toBe(true);
});
it("partial-merges an omitted field (keeps the stored value)", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
// Only fever supplied — functionalImpact must keep the stored 3.
await upsertIllnessDayLog("u1", "ep1", { date: "2026-06-16", feverC: 37 }, null);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.functionalImpact).toBe(3);
expect(args.update.feverC).toBe(37);
});
it("clears a field on an explicit null", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", feverC: null },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.feverC).toBeNull();
});
it("encrypts a supplied note and skips link replace when symptoms omitted", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", note: "rough night" },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.create.noteEncrypted).toBeInstanceOf(Uint8Array);
expect(db.illnessSymptomLink.deleteMany).not.toHaveBeenCalled();
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* v1.18.1 — illness route gate.
*
* `requireIllnessEnabled` delegates to the module gate but re-stamps the
* illness-specific errorCode. Asserts the enabled pass, the disabled 403
* envelope shape, and that `isIllnessEnabled` mirrors `isModuleEnabled`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const moduleGate = vi.hoisted(() => ({
isModuleEnabled: vi.fn(),
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => moduleGate);
import {
ILLNESS_DISABLED_ERROR_CODE,
isIllnessEnabled,
requireIllnessEnabled,
} from "@/lib/illness/gate";
beforeEach(() => {
vi.clearAllMocks();
});
describe("isIllnessEnabled", () => {
it("delegates to isModuleEnabled('illness')", async () => {
moduleGate.isModuleEnabled.mockResolvedValue(true);
await expect(isIllnessEnabled("u1")).resolves.toBe(true);
expect(moduleGate.isModuleEnabled).toHaveBeenCalledWith("u1", "illness");
});
});
describe("requireIllnessEnabled", () => {
it("passes when the module gate passes", async () => {
moduleGate.requireModuleEnabled.mockResolvedValue({ enabled: true });
const gate = await requireIllnessEnabled("u1");
expect(gate.enabled).toBe(true);
});
it("returns a 403 with the illness errorCode when disabled", async () => {
moduleGate.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
const gate = await requireIllnessEnabled("u1");
expect(gate.enabled).toBe(false);
if (gate.enabled) throw new Error("expected disabled");
expect(gate.response.status).toBe(403);
const body = await gate.response.json();
expect(body.meta.errorCode).toBe(ILLNESS_DISABLED_ERROR_CODE);
expect(body.meta.module).toBe("illness");
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* Illness day-log write helper (v1.18.1) — the upsert behind
* `POST .../episodes/{id}/day-logs`.
*
* UPSERT key: the canonical `(episodeId, date)`. `note` → `noteEncrypted`
* (AES-256-GCM via the shared Bytes codec). Symptom links carry a 0–3
* Jackson/WURSS severity (NULL = a plain presence link).
*
* Partial merge: a re-post that omits a field never nulls a previously
* stored value — the UPDATE branch only overwrites the fields actually
* present in the input. An explicit `null` clears.
*
* Symptom-key resolution is scoped to the global seeded catalog (illness
* symptoms have no per-user custom rows in the MVP); unknown keys are
* dropped silently.
*/
import { prisma } from "@/lib/db";
import {
decryptFromBytes,
encryptToBytes,
} from "@/lib/ai/coach/bytes-codec";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
export interface IllnessDayLogWriteResult {
id: string;
existed: boolean;
}
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
export interface IllnessSymptomSelection {
key: string;
severity?: number | null;
}
/**
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown / inactive
* keys silently.
*/
export async function resolveIllnessSymptomIds(
keys: readonly string[],
): Promise<{ key: string; id: string }[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
const rows = await prisma.illnessSymptom.findMany({
where: { key: { in: unique }, isActive: true },
select: { id: true, key: true },
});
return rows.map((r) => ({ key: r.key, id: r.id }));
}
/**
* Replace the symptom-link set for a day-log. Each selection's optional
* 0–3 severity is persisted on the link (NULL = a plain presence link).
*/
export async function replaceIllnessSymptomLinks(
dayLogId: string,
selections: readonly IllnessSymptomSelection[],
): Promise<void> {
const resolved = await resolveIllnessSymptomIds(
selections.map((s) => s.key),
);
const severityByKey = new Map(
selections.map((s) => [
s.key,
typeof s.severity === "number" ? s.severity : null,
]),
);
await prisma.illnessSymptomLink.deleteMany({ where: { dayLogId } });
if (resolved.length > 0) {
await prisma.illnessSymptomLink.createMany({
data: resolved.map(({ key, id }) => ({
dayLogId,
symptomId: id,
severity: severityByKey.get(key) ?? null,
})),
skipDuplicates: true,
});
}
}
/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */
function decryptNoteSoft(noteEncrypted: Uint8Array | null): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch {
return null;
}
}
/**
* Upsert one illness day-log on the `(episodeId, date)` key. The caller is
* responsible for verifying the episode is owned + live before calling.
* `tz` anchors the date string.
*/
export async function upsertIllnessDayLog(
userId: string,
episodeId: string,
entry: IllnessDayLogInput,
tz: string | null,
): Promise<IllnessDayLogWriteResult> {
const where = { episodeId_date: { episodeId, date: entry.date } };
const existing = await prisma.illnessDayLog.findUnique({
where,
select: {
id: true,
functionalImpact: true,
feverC: true,
noteEncrypted: true,
},
});
// Partial merge against the stored row.
const functionalImpact =
entry.functionalImpact !== undefined
? (entry.functionalImpact ?? null)
: (existing?.functionalImpact ?? null);
const feverC =
entry.feverC !== undefined
? (entry.feverC ?? null)
: (existing?.feverC ?? null);
const incomingNote =
entry.note !== undefined
? (entry.note ?? null)
: decryptNoteSoft(existing?.noteEncrypted ?? null);
const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null;
const row = await prisma.illnessDayLog.upsert({
where,
create: {
userId,
episodeId,
date: entry.date,
tz,
functionalImpact,
feverC,
noteEncrypted,
},
update: {
functionalImpact,
feverC,
noteEncrypted,
deletedAt: null,
},
select: { id: true },
});
if (entry.symptoms !== undefined) {
await replaceIllnessSymptomLinks(row.id, entry.symptoms);
}
return { id: row.id, existed: existing !== null };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Illness row → wire-DTO mappers (v1.18.1).
*
* The canonical `IllnessEpisodeDTO` and `IllnessDayLogDTO` shapes the iOS
* client mirrors (server-authoritative — iOS renders, never recomputes).
* Free-text notes live in `noteEncrypted` (Bytes) and are decrypted
* fail-soft on read (a key-rotation gap on one row reads `null`, never
* 500s the whole page). Symptom links flatten to `[{ key, severity }]`
* using the catalog key; `severity` carries the persisted per-link 0–3
* Jackson/WURSS intensity (NULL = a plain presence link).
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import type {
IllnessEpisode,
IllnessDayLog,
IllnessSymptom,
IllnessSymptomLink,
} from "@/generated/prisma/client";
export interface IllnessSymptomDTO {
key: string;
severity: number | null;
}
export interface IllnessEpisodeDTO {
id: string;
label: string;
type: string;
lifecycle: string;
onsetAt: string;
resolvedAt: string | null;
parentConditionId: string | null;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface IllnessDayLogDTO {
id: string;
episodeId: string;
date: string;
functionalImpact: number | null;
feverC: number | null;
symptoms: IllnessSymptomDTO[];
note: string | null;
updatedAt: string;
}
/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */
function decryptNote(noteEncrypted: Uint8Array | null): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch {
return null;
}
}
export function toIllnessEpisodeDTO(row: IllnessEpisode): IllnessEpisodeDTO {
return {
id: row.id,
label: row.label,
type: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
parentConditionId: row.parentConditionId,
note: decryptNote(row.noteEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
type DayLogWithLinks = IllnessDayLog & {
symptomLinks?: (IllnessSymptomLink & {
symptom: Pick<IllnessSymptom, "key">;
})[];
};
/** Prisma `include` that flattens the symptom links for the DTO mapper. */
export const dayLogSymptomInclude = {
symptomLinks: { include: { symptom: { select: { key: true } } } },
} as const;
export function toIllnessDayLogDTO(row: DayLogWithLinks): IllnessDayLogDTO {
return {
id: row.id,
episodeId: row.episodeId,
date: row.date,
functionalImpact: row.functionalImpact,
feverC: row.feverC,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: l.severity ?? null,
})),
note: decryptNote(row.noteEncrypted),
updatedAt: row.updatedAt.toISOString(),
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Illness / condition-journal feature gate (v1.18.1).
*
* The illness journal is BORN-GATED (opt-in / default-off) and resolves
* its enabled-state through the module foundation — there is no per-domain
* profile row and no second source of truth. `isIllnessEnabled` and the
* `requireIllnessEnabled` 403 guard both delegate to the module gate's
* `isModuleEnabled(userId, "illness")`, which (for a born-gated key) reads
* an explicit `modulePreferencesJson.illness === true` AND the operator
* server-wide availability. This mirrors `src/lib/cycle/gate.ts` so every
* `/api/illness/*` route gates identically.
*/
import { apiError } from "@/lib/api-response";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
/** Wire `errorCode` the iOS retry classifier branches on. */
export const ILLNESS_DISABLED_ERROR_CODE = "illness.disabled";
/**
* Fully-resolved illness availability for an account: the born-gated opt-in
* AND the operator server-wide kill-switch, via the module foundation.
*/
export function isIllnessEnabled(userId: string): Promise<boolean> {
return isModuleEnabled(userId, "illness");
}
/**
* Route-guard outcome: a clear pass or a ready 403 envelope to return
* verbatim — mirrors `CycleGateResult`.
*/
export type IllnessGateResult =
| { enabled: true }
| { enabled: false; response: Response };
/**
* Enforce the gate for an `/api/illness/*` route. Returns a 403
* `illness.disabled` envelope when the account has not opted in (or the
* operator turned the module off server-wide) — even with a valid Bearer
* token. Delegates to `requireModuleEnabled` but re-stamps the
* illness-specific `errorCode` so the iOS classifier branches cleanly.
*/
export async function requireIllnessEnabled(
userId: string,
): Promise<IllnessGateResult> {
const gate = await requireModuleEnabled(userId, "illness");
if (gate.enabled) return { enabled: true };
return {
enabled: false,
response: apiError("Illness journal is not enabled", 403, {
errorCode: ILLNESS_DISABLED_ERROR_CODE,
module: "illness",
}),
};
}