mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
bee7295b19
commit
b079431a8d
34 files changed
+2799
-6
No files matched your search
@@ -77,6 +77,7 @@ function moduleMap(
|
||||
workouts: true,
|
||||
recovery: true,
|
||||
labs: true,
|
||||
illness: true,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,54 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
|
||||
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
|
||||
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is ON only on an explicit true", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: true } }),
|
||||
true,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF on an explicit false", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: false } }),
|
||||
true,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("operator-off short-circuits even when the user opted in", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: true } }),
|
||||
true,
|
||||
operator({ illness: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not affect sibling default-on modules", () => {
|
||||
const i = inputs({ modulePreferences: {} });
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
|
||||
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — cycle delegation", () => {
|
||||
it("ignores the module blob and reads the cycle gate (gender-derived)", () => {
|
||||
// Even a crafted `{ cycle: false }` blob cannot override the real
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from "./operator-availability";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
isBornGatedModule,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "./registry";
|
||||
@@ -146,6 +147,13 @@ export function resolveModuleEnabled(
|
||||
return assistantCoach && !inputs.disableCoach;
|
||||
}
|
||||
|
||||
if (isBornGatedModule(key)) {
|
||||
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
|
||||
// The module stays OFF until the account explicitly enables it — only a
|
||||
// literal `true` turns it on, so the surface never appears unbidden.
|
||||
return inputs.modulePreferences[key] === true;
|
||||
}
|
||||
|
||||
// Disabled allowlist: only an explicit `false` turns the module off.
|
||||
return inputs.modulePreferences[key] !== false;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ export interface ModuleDefinition {
|
||||
* link; `labelKey` names the destination ("Account", "Coach settings").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
/**
|
||||
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
|
||||
* the inverse of the standard disabled-allowlist: it stays OFF until the
|
||||
* account explicitly enables it (`modulePreferencesJson[key] === true`).
|
||||
* Used for new optional verticals (the illness/condition journal) that
|
||||
* ship dark and only appear once the user opts in from the Modules hub.
|
||||
* The gate's `false`-disables contract still holds — an explicit `false`
|
||||
* (or simply the absent default) keeps it off — but here ONLY an explicit
|
||||
* `true` turns it on, so the surface never appears unbidden.
|
||||
*/
|
||||
bornGated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +99,7 @@ export const MODULE_KEYS = [
|
||||
"workouts",
|
||||
"recovery",
|
||||
"labs",
|
||||
"illness",
|
||||
"achievements",
|
||||
"coach",
|
||||
"insights",
|
||||
@@ -169,6 +181,15 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.labs.description",
|
||||
category: "tracking",
|
||||
},
|
||||
illness: {
|
||||
key: "illness",
|
||||
labelKey: "modules.illness.label",
|
||||
descriptionKey: "modules.illness.description",
|
||||
category: "tracking",
|
||||
// Ships dark — only appears once the account opts in from the
|
||||
// Modules hub. Default-off, no nag, retrospective-only.
|
||||
bornGated: true,
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
labelKey: "modules.sleep.label",
|
||||
@@ -230,3 +251,12 @@ export function isModuleKey(key: string): key is ModuleKey {
|
||||
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
|
||||
return MODULE_REGISTRY[key].delegatesTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
|
||||
* flips its default from on to off for these keys: they require an explicit
|
||||
* `modulePreferencesJson[key] === true` to enable.
|
||||
*/
|
||||
export function isBornGatedModule(key: ModuleKey): boolean {
|
||||
return MODULE_REGISTRY[key].bornGated === true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Query keys — illness / condition journal (v1.18.1): the episode list,
|
||||
* a single episode, and a single episode-day day-log.
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const illnessKeys = {
|
||||
/**
|
||||
* v1.18.1 — illness-journal surfaces. `illness()` is the root prefix
|
||||
* every illness write invalidates through (`illnessDependentKeys` in
|
||||
* `./index.ts`). The episode list, a single episode read, and a per-day
|
||||
* day-log each get their own slot; any episode / day-log write evicts
|
||||
* the whole `["illness"]` prefix so the history list and the open sheet
|
||||
* repaint in lockstep.
|
||||
*/
|
||||
illness: () => ["illness"] as const,
|
||||
illnessEpisodes: (includeResolved: boolean) =>
|
||||
["illness", "episodes", includeResolved] as const,
|
||||
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
|
||||
illnessDayLog: (episodeId: string, date: string) =>
|
||||
["illness", "day-log", episodeId, date] as const,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { authKeys } from "./auth";
|
||||
import { coachKeys } from "./coach";
|
||||
import { cycleKeys } from "./cycle";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { illnessKeys } from "./illness";
|
||||
import { insightsKeys } from "./insights";
|
||||
import { integrationKeys } from "./integrations";
|
||||
import { labKeys } from "./labs";
|
||||
@@ -44,6 +45,7 @@ export const queryKeys = {
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
...labKeys,
|
||||
...illnessKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Illness / condition-journal request/response validation (v1.18.1).
|
||||
*
|
||||
* Source of truth for the `/api/illness/*` wire contract. The Zod schemas
|
||||
* here are reused by the OpenAPI registry so the spec stays single-source.
|
||||
* `userId` is NEVER a body field — it is narrowed from the session/Bearer
|
||||
* in every route and fed to the Prisma `where`.
|
||||
*
|
||||
* The journal is a CONDITION journal (any illness/condition kept for
|
||||
* oneself), retrospective-only — NEVER a predictor/diagnoser. Day-log
|
||||
* dates are `YYYY-MM-DD` strings (the CycleDayLog / MoodEntry tz-anchored
|
||||
* convention); instants are ISO-8601 with offset. Free-text notes are
|
||||
* encrypted at rest (`noteEncrypted` Bytes column); every other field is
|
||||
* queryable plaintext.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/**
|
||||
* Plausible-instant bound shared with the measurement / mood / cycle
|
||||
* paths: no future instant beyond the 5-min skew, no instant before 1900.
|
||||
* Stays a string on the wire (iOS reads it back verbatim).
|
||||
*/
|
||||
const boundedInstant = z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
message: "must be a plausible instant (not future, not pre-1900)",
|
||||
});
|
||||
|
||||
const dateString = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
|
||||
|
||||
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
|
||||
|
||||
export const illnessTypeEnum = z.enum([
|
||||
"INFECTION",
|
||||
"ALLERGY",
|
||||
"INJURY",
|
||||
"MENTAL_HEALTH",
|
||||
"AUTOIMMUNE",
|
||||
"CHRONIC",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
export const illnessLifecycleEnum = z.enum([
|
||||
"ACUTE",
|
||||
"CHRONIC_ONGOING",
|
||||
"RECURRING",
|
||||
"FLARE",
|
||||
]);
|
||||
|
||||
/* ── episode CRUD ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Create an episode. `label` is the user-facing name; `note` is encrypted
|
||||
* at rest. `parentConditionId` threads a FLARE/RECURRING bout under a
|
||||
* parent condition. `onsetAt` defaults to "now" server-side when omitted.
|
||||
*/
|
||||
export const illnessEpisodeCreateSchema = z.object({
|
||||
label: z.string().min(1).max(120),
|
||||
type: illnessTypeEnum,
|
||||
lifecycle: illnessLifecycleEnum.optional().default("ACUTE"),
|
||||
onsetAt: boundedInstant.optional(),
|
||||
resolvedAt: boundedInstant.nullable().optional(),
|
||||
parentConditionId: z.string().min(1).max(40).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type IllnessEpisodeCreate = z.infer<typeof illnessEpisodeCreateSchema>;
|
||||
|
||||
/**
|
||||
* Edit an episode — every field optional; an omitted field is left
|
||||
* untouched. A `null` `resolvedAt` re-opens a resolved episode.
|
||||
*/
|
||||
export const illnessEpisodeUpdateSchema = z
|
||||
.object({
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
type: illnessTypeEnum.optional(),
|
||||
lifecycle: illnessLifecycleEnum.optional(),
|
||||
onsetAt: boundedInstant.optional(),
|
||||
resolvedAt: boundedInstant.nullable().optional(),
|
||||
parentConditionId: z.string().min(1).max(40).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type IllnessEpisodeUpdate = z.infer<typeof illnessEpisodeUpdateSchema>;
|
||||
|
||||
/** Resolve (mark recovered) — a dedicated PATCH `/resolve` endpoint. */
|
||||
export const illnessEpisodeResolveSchema = z.object({
|
||||
resolvedAt: boundedInstant.optional(),
|
||||
});
|
||||
|
||||
export type IllnessEpisodeResolve = z.infer<
|
||||
typeof illnessEpisodeResolveSchema
|
||||
>;
|
||||
|
||||
/** History/list query — newest-first, bounded. */
|
||||
export const illnessEpisodeListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
includeResolved: z
|
||||
.union([z.literal("true"), z.literal("false")])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/* ── day-log capture (one row per day of an episode) ─────────────── */
|
||||
|
||||
/**
|
||||
* The canonical day-log input for an episode. Upserts on the
|
||||
* `(episodeId, date)` key. `note` is encrypted at rest (`noteEncrypted`);
|
||||
* `functionalImpact` (0–3) and `feverC` are queryable plaintext. Symptoms
|
||||
* carry a 0–3 Jackson/WURSS severity on the link.
|
||||
*/
|
||||
export const illnessDayLogInputSchema = z.object({
|
||||
date: dateString,
|
||||
functionalImpact: z.number().int().min(0).max(3).nullable().optional(),
|
||||
feverC: z.number().min(30).max(45).nullable().optional(),
|
||||
symptoms: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1).max(80),
|
||||
severity: z.number().int().min(0).max(3).optional(),
|
||||
}),
|
||||
)
|
||||
.max(40)
|
||||
.optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
loggedAt: boundedInstant.optional(),
|
||||
});
|
||||
|
||||
export type IllnessDayLogInput = z.infer<typeof illnessDayLogInputSchema>;
|
||||
|
||||
/** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */
|
||||
export const illnessDayLogQuerySchema = z.object({ date: dateString });
|
||||
Reference in new issue
Block a user