Files
HealthLog/src/lib/illness/gate.ts
T
Marc-André Bombeck b079431a8d 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).
2026-06-16 21:09:35 +02:00

55 lines
2.1 KiB
TypeScript

/**
* 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",
}),
};
}