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

+135
View File
@@ -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 });