Files
HealthLog/src/lib/illness/dto.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

100 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(),
};
}