mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -75,6 +75,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/workouts",
|
||||
"src/app/api/labs",
|
||||
"src/app/api/cycle",
|
||||
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
|
||||
// route gates on the born-gated `illness` module via the thin
|
||||
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
|
||||
// illness-specific errorCode over `requireModuleEnabled("illness")`),
|
||||
// recognised below as a delegated gate. Walking the tree means a NEW
|
||||
// ungated illness route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account never opted in.
|
||||
"src/app/api/illness",
|
||||
"src/app/api/gamification",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
// availability probe) was orphaned dead code (no production caller) and
|
||||
@@ -168,6 +176,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
|
||||
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
|
||||
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
|
||||
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
|
||||
// are not flagged as ungated.
|
||||
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
|
||||
// Builder aggregators that resolve `resolveModuleMap` once and exclude
|
||||
// disabled-module sections/resources at the build boundary.
|
||||
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
|
||||
@@ -251,6 +265,7 @@ describe("module API route gate inventory", () => {
|
||||
"workouts",
|
||||
"recovery",
|
||||
"labs",
|
||||
"illness",
|
||||
"achievements",
|
||||
"cycle",
|
||||
"coach",
|
||||
@@ -276,6 +291,7 @@ describe("module API route gate inventory", () => {
|
||||
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
|
||||
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
|
||||
|
||||
if (exempt.has(path)) continue;
|
||||
@@ -346,6 +362,7 @@ describe("module API route gate inventory", () => {
|
||||
fileHasCall(text, MODULE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, COACH_GATE_NEEDLE) ||
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
|
||||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
|
||||
) {
|
||||
stillGated.push(path);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* `GET /api/illness/episodes/{id}/day-logs?date=YYYY-MM-DD` — the one
|
||||
* day-log for the episode + day, or `null` when nothing is logged
|
||||
* (lets the log-day sheet pre-fill).
|
||||
* `POST /api/illness/episodes/{id}/day-logs` — upsert the day's symptom /
|
||||
* functional-impact / fever timeline row. Upserts on
|
||||
* `(episodeId, date)`: 201 on insert, 200 on update.
|
||||
*
|
||||
* Born-gated + owner-scoped (the episode must be owned + live). `note` is
|
||||
* encrypted at rest; `userId` is narrowed from auth, never a body field.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import {
|
||||
illnessDayLogInputSchema,
|
||||
illnessDayLogQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
|
||||
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Resolve the episode, 404ing unless it is owned + live. */
|
||||
async function loadOwnedEpisode(
|
||||
episodeId: string,
|
||||
userId: string,
|
||||
): Promise<{ ok: true } | { ok: false; response: Response }> {
|
||||
const episode = await prisma.illnessEpisode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, deletedAt: true },
|
||||
});
|
||||
if (!episode || episode.userId !== userId || episode.deletedAt !== null) {
|
||||
return { ok: false, response: apiError("Episode not found", 404) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const owned = await loadOwnedEpisode(id, user.id);
|
||||
if (!owned.ok) return owned.response;
|
||||
|
||||
const parsed = illnessDayLogQuerySchema.safeParse({
|
||||
date: new URL(request.url).searchParams.get("date"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const row = await prisma.illnessDayLog.findFirst({
|
||||
where: { episodeId: id, date: parsed.data.date, deletedAt: null },
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.day-log.read",
|
||||
entity_type: "illness_day_log",
|
||||
},
|
||||
meta: { found: row !== null },
|
||||
});
|
||||
|
||||
return apiSuccess(row ? toIllnessDayLogDTO(row) : null);
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const owned = await loadOwnedEpisode(id, user.id);
|
||||
if (!owned.ok) return owned.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = illnessDayLogInputSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "illness.day-log.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz);
|
||||
|
||||
const row = await prisma.illnessDayLog.findUniqueOrThrow({
|
||||
where: { id: result.id },
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
await auditLog("illness.day-log.upsert", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: result.id, episodeId: id, existed: result.existed },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.day-log.upsert",
|
||||
entity_type: "illness_day_log",
|
||||
entity_id: result.id,
|
||||
},
|
||||
meta: { existed: result.existed },
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessDayLogDTO(row), result.existed ? 200 : 201);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* `PATCH /api/illness/episodes/{id}/resolve` — mark an episode recovered.
|
||||
*
|
||||
* Sets `resolvedAt` (defaults to "now" when the body omits it). A
|
||||
* dedicated verb so the history surface's one-tap "mark recovered" never
|
||||
* has to send the full edit body. Born-gated + owner-scoped. A
|
||||
* CHRONIC_ONGOING episode has no "recovered" date by design — resolving
|
||||
* one is refused.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { illnessEpisodeResolveSchema } from "@/lib/validations/illness";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.illnessEpisode.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true, lifecycle: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
if (existing.lifecycle === "CHRONIC_ONGOING") {
|
||||
return apiError("A chronic-ongoing condition has no recovery date", 422, {
|
||||
errorCode: "illness.episode.chronic-no-resolve",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.episode.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedAt = parsed.data.resolvedAt
|
||||
? new Date(parsed.data.resolvedAt)
|
||||
: new Date();
|
||||
|
||||
const updated = await prisma.illnessEpisode.update({
|
||||
where: { id },
|
||||
data: { resolvedAt },
|
||||
});
|
||||
|
||||
await auditLog("illness.episode.resolve", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.resolve",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessEpisodeDTO(updated));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* `GET /api/illness/episodes/{id}` — one owned, live episode.
|
||||
* `PATCH /api/illness/episodes/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent, 204).
|
||||
*
|
||||
* Born-gated + owner-scoped. `userId` is narrowed from auth and fed to the
|
||||
* Prisma `where`; the body never carries it. `note` is encrypted at rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.illnessEpisode.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.read",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessEpisodeDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.illnessEpisode.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = illnessEpisodeUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.episode.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
// A re-parent must point at an owned, live episode.
|
||||
if (entry.parentConditionId) {
|
||||
const parent = await prisma.illnessEpisode.findUnique({
|
||||
where: { id: entry.parentConditionId },
|
||||
select: { userId: true, deletedAt: true },
|
||||
});
|
||||
if (
|
||||
entry.parentConditionId === id ||
|
||||
!parent ||
|
||||
parent.userId !== user.id ||
|
||||
parent.deletedAt !== null
|
||||
) {
|
||||
return apiError("Parent condition not found", 404, {
|
||||
errorCode: "illness.episode.parent-not-found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field — only fields actually present in the body are written.
|
||||
const data: Prisma.IllnessEpisodeUpdateInput = {};
|
||||
if (entry.label !== undefined) data.label = entry.label;
|
||||
if (entry.type !== undefined) data.type = entry.type;
|
||||
if (entry.lifecycle !== undefined) data.lifecycle = entry.lifecycle;
|
||||
if (entry.onsetAt !== undefined) data.onsetAt = new Date(entry.onsetAt);
|
||||
if (entry.resolvedAt !== undefined) {
|
||||
data.resolvedAt = entry.resolvedAt ? new Date(entry.resolvedAt) : null;
|
||||
}
|
||||
if (entry.parentConditionId !== undefined) {
|
||||
data.parent = entry.parentConditionId
|
||||
? { connect: { id: entry.parentConditionId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (entry.note !== undefined) {
|
||||
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.illnessEpisode.update({ where: { id }, data });
|
||||
|
||||
await auditLog("illness.episode.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.update",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessEpisodeDTO(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.illnessEpisode.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Episode not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.illnessEpisode.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("illness.episode.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.delete",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* `GET /api/illness/episodes` — newest-first list of the account's
|
||||
* illness/condition episodes.
|
||||
* `POST /api/illness/episodes` — create one episode.
|
||||
*
|
||||
* The illness journal is a CONDITION journal, retrospective-only — NEVER a
|
||||
* predictor/diagnoser. Born-gated: a non-opted-in account (or an
|
||||
* operator-disabled instance) 403s with `errorCode:"illness.disabled"`
|
||||
* even with a valid Bearer token. `userId` is narrowed from auth and fed
|
||||
* to the Prisma `where`; it is never a body field. The `data` object is
|
||||
* built field-by-field (no mass assignment); `note` is encrypted at rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
illnessEpisodeCreateSchema,
|
||||
illnessEpisodeListQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = illnessEpisodeListQuerySchema.safeParse({
|
||||
limit: params.get("limit") ?? undefined,
|
||||
includeResolved: params.get("includeResolved") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.episode.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 50;
|
||||
const rows = await prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
|
||||
},
|
||||
orderBy: { onsetAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "illness.episode.list", entity_type: "illness_episode" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toIllnessEpisodeDTO));
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = illnessEpisodeCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "illness.episode.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "illness.episode.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// A FLARE/RECURRING bout may hang off a parent condition — verify the
|
||||
// parent is owned + live before threading.
|
||||
if (entry.parentConditionId) {
|
||||
const parent = await prisma.illnessEpisode.findUnique({
|
||||
where: { id: entry.parentConditionId },
|
||||
select: { userId: true, deletedAt: true },
|
||||
});
|
||||
if (!parent || parent.userId !== user.id || parent.deletedAt !== null) {
|
||||
return apiError("Parent condition not found", 404, {
|
||||
errorCode: "illness.episode.parent-not-found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const created = await prisma.illnessEpisode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: entry.label,
|
||||
type: entry.type,
|
||||
lifecycle: entry.lifecycle,
|
||||
onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : new Date(),
|
||||
resolvedAt: entry.resolvedAt ? new Date(entry.resolvedAt) : null,
|
||||
parentConditionId: entry.parentConditionId ?? null,
|
||||
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("illness.episode.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: created.id, type: created.type },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.create",
|
||||
entity_type: "illness_episode",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { type: created.type, lifecycle: created.lifecycle },
|
||||
});
|
||||
|
||||
return apiSuccess(toIllnessEpisodeDTO(created), 201);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { IllnessView } from "@/components/illness/illness-view";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the illness / condition-journal entry.
|
||||
*
|
||||
* Born-gated on the resolved `modules.illness` flag from `GET /api/auth/me`
|
||||
* (opt-in / default-off; the per-user opt-in AND the operator server-wide
|
||||
* kill-switch). An unauthenticated visitor is bounced to login; an
|
||||
* authenticated account without the module opted in is bounced home (the
|
||||
* nav entry is already hidden for them, so this only catches a direct URL
|
||||
* hit). Every `/api/illness/*` route also enforces the gate server-side,
|
||||
* so this is a UX redirect, not the security boundary.
|
||||
*/
|
||||
export default function IllnessPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Born-gated: enabled ONLY when the resolved map says `true`.
|
||||
const enabled = user?.modules?.illness === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IllnessView />;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the illness / condition-journal surface.
|
||||
*
|
||||
* A calm, retrospective journal: the episode history (newest first) with a
|
||||
* neutral status Badge (active / recovered — never an alarming colour, the
|
||||
* med-card rule generalised), a "log a day" entry into the day-log sheet,
|
||||
* and a "mark recovered" one-tap. Retrospective-only copy: this is a
|
||||
* journal, not a medical device, and it does not diagnose. No prediction
|
||||
* UI by design.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
import {
|
||||
useDeleteEpisode,
|
||||
useIllnessEpisodes,
|
||||
useResolveEpisode,
|
||||
} from "./use-illness";
|
||||
import type { IllnessEpisodeDTO } from "./types";
|
||||
|
||||
/** Today as a YYYY-MM-DD string in the viewer's local calendar. */
|
||||
function todayLocal(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function EpisodeRow({
|
||||
episode,
|
||||
onLogDay,
|
||||
onResolve,
|
||||
onDelete,
|
||||
resolving,
|
||||
}: {
|
||||
episode: IllnessEpisodeDTO;
|
||||
onLogDay: (id: string) => void;
|
||||
onResolve: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
resolving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{episode.label}</span>
|
||||
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
|
||||
{episode.resolvedAt
|
||||
? ` · ${t("illness.recoveredOn", {
|
||||
date: fmt.dateShort(new Date(episode.resolvedAt)),
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onLogDay(episode.id)}>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{active && !isChronic ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={resolving}
|
||||
onClick={() => onResolve(episode.id)}
|
||||
>
|
||||
{t("illness.markRecovered")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => onDelete(episode.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const del = useDeleteEpisode();
|
||||
|
||||
const today = todayLocal();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("illness.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("illness.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
|
||||
) : episodes && episodes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
onDelete={(id) => del.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex flex-col items-center gap-3 p-10 text-center">
|
||||
<Stethoscope className="text-muted-foreground h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-medium">{t("illness.empty.title")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.empty.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<NewEpisodeSheet open={newOpen} onOpenChange={setNewOpen} today={today} />
|
||||
{logEpisodeId ? (
|
||||
<LogDaySheet
|
||||
open={logEpisodeId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLogEpisodeId(null);
|
||||
}}
|
||||
episodeId={logEpisodeId}
|
||||
date={today}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — illness day-log sheet. The daily symptom timeline for one
|
||||
* episode-day: ~8 Jackson/WURSS symptom chips with a 0–3 severity, a
|
||||
* coarse functional-impact chip group (0 = fully functional … 3 =
|
||||
* bedbound), an optional fever reading, and an encrypted free-text note.
|
||||
*
|
||||
* Structurally a fork of the cycle `log-day-sheet`: the shared
|
||||
* `ResponsiveSheet` + `SheetSection` primitives, a `Chip`/`SymptomChip`
|
||||
* vocabulary, the envelope-aware pre-fill, and the `["illness"]`-prefix
|
||||
* invalidation on write. Neutral palette throughout — no alarming colour;
|
||||
* status reads via discreet text, never a red tint (the med-card rule
|
||||
* generalised).
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Activity, NotebookPen, Thermometer } from "lucide-react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SheetSection,
|
||||
SheetSectionCount,
|
||||
} from "@/components/ui/sheet-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
|
||||
import type { IllnessDayLogInput } from "./types";
|
||||
|
||||
interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
episodeId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** Mini toggle chip — the shared selection vocabulary. */
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A symptom chip with an inline 0–3 severity selector once active. */
|
||||
function SymptomChip({
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
severity,
|
||||
onToggle,
|
||||
onSeverity,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
active: boolean;
|
||||
severity: number | null;
|
||||
onToggle: () => void;
|
||||
onSeverity: (value: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-1 pl-2"
|
||||
role="group"
|
||||
aria-label={t("illness.sheet.severityFor", { symptom: label })}
|
||||
>
|
||||
{[1, 2, 3].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={severity === value}
|
||||
onClick={() => onSeverity(value)}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md border text-xs",
|
||||
severity === value
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogDaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
episodeId,
|
||||
date,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const dayLog = useIllnessDayLog(open ? episodeId : null, date);
|
||||
const upsert = useUpsertDayLog(episodeId);
|
||||
|
||||
const [symptoms, setSymptoms] = useState<Map<string, number | null>>(
|
||||
new Map(),
|
||||
);
|
||||
const [functionalImpact, setFunctionalImpact] = useState<number | null>(null);
|
||||
const [fever, setFever] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
|
||||
// tuple — React's recommended "adjust state during render keyed on a prop"
|
||||
// alternative to a setState-in-effect (the cycle log-day-sheet precedent).
|
||||
// `dto === null` (nothing logged) resets to blank.
|
||||
const dto = open ? (dayLog.data ?? null) : undefined;
|
||||
const formKey = open
|
||||
? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
|
||||
: null;
|
||||
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
|
||||
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
|
||||
setLastFormKey(formKey);
|
||||
setSymptoms(
|
||||
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
|
||||
);
|
||||
setFunctionalImpact(dto?.functionalImpact ?? null);
|
||||
setFever(dto?.feverC != null ? String(dto.feverC) : "");
|
||||
setNote(dto?.note ?? "");
|
||||
} else if (!open && lastFormKey !== null) {
|
||||
setLastFormKey(null);
|
||||
}
|
||||
|
||||
const symptomCount = symptoms.size;
|
||||
|
||||
function toggleSymptom(key: string) {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.set(key, null);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setSeverity(key: string, value: number) {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, prev.get(key) === value ? null : value);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const feverValue = useMemo(() => {
|
||||
if (fever.trim() === "") return null;
|
||||
const parsed = Number.parseFloat(fever.replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [fever]);
|
||||
|
||||
async function handleSave() {
|
||||
const input: IllnessDayLogInput = {
|
||||
date,
|
||||
functionalImpact,
|
||||
feverC: feverValue,
|
||||
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
|
||||
key,
|
||||
severity: severity ?? undefined,
|
||||
})),
|
||||
note: note.trim() === "" ? null : note,
|
||||
};
|
||||
try {
|
||||
await upsert.mutateAsync(input);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// Keep the sheet open; the error strip below reports it.
|
||||
}
|
||||
}
|
||||
|
||||
const impactLabels = [
|
||||
t("illness.impact.0"),
|
||||
t("illness.impact.1"),
|
||||
t("illness.impact.2"),
|
||||
t("illness.impact.3"),
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("illness.sheet.title", { date })}
|
||||
description={t("illness.sheet.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={upsert.isPending}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<SheetSection
|
||||
title={t("illness.sheet.symptoms")}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
summary={<SheetSectionCount count={symptomCount} />}
|
||||
defaultOpen
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{ILLNESS_SYMPTOM_CATALOG.map((s) => (
|
||||
<SymptomChip
|
||||
key={s.key}
|
||||
label={t(s.labelKey)}
|
||||
icon={s.icon}
|
||||
active={symptoms.has(s.key)}
|
||||
severity={symptoms.get(s.key) ?? null}
|
||||
onToggle={() => toggleSymptom(s.key)}
|
||||
onSeverity={(value) => setSeverity(s.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t("illness.sheet.functionalImpact")}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
summary={
|
||||
functionalImpact != null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{impactLabels[functionalImpact]}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<Chip
|
||||
key={value}
|
||||
active={functionalImpact === value}
|
||||
onClick={() =>
|
||||
setFunctionalImpact(
|
||||
functionalImpact === value ? null : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{impactLabels[value]}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t("illness.sheet.fever")}
|
||||
icon={<Thermometer className="h-4 w-4" />}
|
||||
summary={
|
||||
feverValue != null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{feverValue.toFixed(1)} °C
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="pt-2">
|
||||
<Label htmlFor="illness-fever" className="sr-only">
|
||||
{t("illness.sheet.fever")}
|
||||
</Label>
|
||||
<Input
|
||||
id="illness-fever"
|
||||
inputMode="decimal"
|
||||
placeholder="37.0"
|
||||
value={fever}
|
||||
onChange={(e) => setFever(e.target.value)}
|
||||
className="max-w-32"
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t("illness.sheet.note")}
|
||||
icon={<NotebookPen className="h-4 w-4" />}
|
||||
summary={
|
||||
note.trim() !== "" ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("illness.sheet.noteSet")}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="pt-2">
|
||||
<Textarea
|
||||
value={note}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("illness.sheet.notePlaceholder")}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{upsert.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("illness.sheet.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the "new episode" capture sheet. A calm, minimal form: a
|
||||
* label, the broad condition type, the lifecycle, an onset date, and an
|
||||
* optional encrypted note. Retrospective-only — no prediction UI, no
|
||||
* diagnosis prompt. Neutral palette throughout.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useCreateEpisode } from "./use-illness";
|
||||
import type { IllnessLifecycle, IllnessType } from "./types";
|
||||
|
||||
const TYPES: IllnessType[] = [
|
||||
"INFECTION",
|
||||
"ALLERGY",
|
||||
"INJURY",
|
||||
"MENTAL_HEALTH",
|
||||
"AUTOIMMUNE",
|
||||
"CHRONIC",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
const LIFECYCLES: IllnessLifecycle[] = [
|
||||
"ACUTE",
|
||||
"CHRONIC_ONGOING",
|
||||
"RECURRING",
|
||||
];
|
||||
|
||||
interface NewEpisodeSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
today: string;
|
||||
}
|
||||
|
||||
export function NewEpisodeSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
today,
|
||||
}: NewEpisodeSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateEpisode();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [type, setType] = useState<IllnessType>("INFECTION");
|
||||
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
|
||||
const [onset, setOnset] = useState(today);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
// Reset the form each time the sheet opens — adjusted during render keyed
|
||||
// on the open transition (React's recommended alternative to a
|
||||
// setState-in-effect).
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open && !wasOpen) {
|
||||
setWasOpen(true);
|
||||
setLabel("");
|
||||
setType("INFECTION");
|
||||
setLifecycle("ACUTE");
|
||||
setOnset(today);
|
||||
setNote("");
|
||||
} else if (!open && wasOpen) {
|
||||
setWasOpen(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (label.trim() === "") return;
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
label: label.trim(),
|
||||
type,
|
||||
lifecycle,
|
||||
// Anchor the date string to local noon so the UTC instant lands on
|
||||
// the intended calendar day regardless of the viewer's offset.
|
||||
onsetAt: new Date(`${onset}T12:00:00`).toISOString(),
|
||||
note: note.trim() === "" ? null : note,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// Keep the sheet open; the error strip reports it.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("illness.new.title")}
|
||||
description={t("illness.new.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={create.isPending || label.trim() === ""}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-label">{t("illness.new.label")}</Label>
|
||||
<Input
|
||||
id="illness-label"
|
||||
value={label}
|
||||
maxLength={120}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("illness.new.labelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as IllnessType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`illness.type.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.lifecycle")}</Label>
|
||||
<Select
|
||||
value={lifecycle}
|
||||
onValueChange={(v) => setLifecycle(v as IllnessLifecycle)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LIFECYCLES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`illness.lifecycle.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-onset">{t("illness.new.onset")}</Label>
|
||||
<Input
|
||||
id="illness-onset"
|
||||
type="date"
|
||||
value={onset}
|
||||
max={today}
|
||||
onChange={(e) => setOnset(e.target.value)}
|
||||
className="max-w-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
|
||||
<Textarea
|
||||
id="illness-note"
|
||||
value={note}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("illness.new.notePlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{create.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("illness.new.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* v1.18.1 — the seeded illness-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The keys are fixed at the migration seed (0171). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the i18n leaf under `illness.symptom.*`; the `icon` is the Lucide name
|
||||
* from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0171_v1181_illness_episode/migration.sql` and the
|
||||
* `illness.symptom.*` i18n leaves.
|
||||
*
|
||||
* The set is Jackson / WURSS-derived (~8 cardinal upper-respiratory + viral
|
||||
* symptoms, 0–3 severity). Fever lives on the day row (`feverC`), not as a
|
||||
* symptom chip; functional impact is a separate slider.
|
||||
*/
|
||||
import {
|
||||
BatteryLow,
|
||||
Brain,
|
||||
Droplets,
|
||||
Flame,
|
||||
Megaphone,
|
||||
PersonStanding,
|
||||
Waves,
|
||||
Wind,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface IllnessSymptom {
|
||||
/** snake_case DB key — the value sent in `symptoms[].key`. */
|
||||
key: string;
|
||||
/** i18n leaf under `illness.symptom.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const ILLNESS_SYMPTOM_CATALOG: IllnessSymptom[] = [
|
||||
{ key: "runny_nose", labelKey: "illness.symptom.runnyNose", icon: Droplets },
|
||||
{ key: "stuffy_nose", labelKey: "illness.symptom.stuffyNose", icon: Wind },
|
||||
{ key: "sneezing", labelKey: "illness.symptom.sneezing", icon: Waves },
|
||||
{ key: "sore_throat", labelKey: "illness.symptom.soreThroat", icon: Flame },
|
||||
{ key: "cough", labelKey: "illness.symptom.cough", icon: Megaphone },
|
||||
{ key: "headache", labelKey: "illness.symptom.headache", icon: Brain },
|
||||
{
|
||||
key: "body_aches",
|
||||
labelKey: "illness.symptom.bodyAches",
|
||||
icon: PersonStanding,
|
||||
},
|
||||
{ key: "fatigue", labelKey: "illness.symptom.fatigue", icon: BatteryLow },
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Client-side illness DTO mirrors (v1.18.1) — the wire shapes the
|
||||
* `/api/illness/*` routes return. Kept in lockstep with
|
||||
* `src/lib/illness/dto.ts` (the server-authoritative source).
|
||||
*/
|
||||
export type IllnessType =
|
||||
| "INFECTION"
|
||||
| "ALLERGY"
|
||||
| "INJURY"
|
||||
| "MENTAL_HEALTH"
|
||||
| "AUTOIMMUNE"
|
||||
| "CHRONIC"
|
||||
| "OTHER";
|
||||
|
||||
export type IllnessLifecycle =
|
||||
| "ACUTE"
|
||||
| "CHRONIC_ONGOING"
|
||||
| "RECURRING"
|
||||
| "FLARE";
|
||||
|
||||
export interface IllnessSymptomSelection {
|
||||
key: string;
|
||||
severity?: number | null;
|
||||
}
|
||||
|
||||
export interface IllnessEpisodeDTO {
|
||||
id: string;
|
||||
label: string;
|
||||
type: IllnessType;
|
||||
lifecycle: IllnessLifecycle;
|
||||
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: IllnessSymptomSelection[];
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IllnessEpisodeCreateInput {
|
||||
label: string;
|
||||
type: IllnessType;
|
||||
lifecycle?: IllnessLifecycle;
|
||||
onsetAt?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface IllnessDayLogInput {
|
||||
date: string;
|
||||
functionalImpact?: number | null;
|
||||
feverC?: number | null;
|
||||
symptoms?: IllnessSymptomSelection[];
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — illness / condition-journal read + write hooks.
|
||||
*
|
||||
* Reads unwrap the envelope `data` (via `apiGet`/`apiPost`) per the project
|
||||
* rule; every key is factory-routed through `queryKeys.illness*`. Writes
|
||||
* invalidate the whole `["illness"]` prefix so the episode history list and
|
||||
* an open day-log sheet repaint in lockstep after a log.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type {
|
||||
IllnessDayLogDTO,
|
||||
IllnessDayLogInput,
|
||||
IllnessEpisodeCreateInput,
|
||||
IllnessEpisodeDTO,
|
||||
} from "./types";
|
||||
|
||||
/** Newest-first episode list. */
|
||||
export function useIllnessEpisodes(includeResolved = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(includeResolved),
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** One episode's day-log for a given date (null when nothing is logged). */
|
||||
export function useIllnessDayLog(episodeId: string | null, date: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.illnessDayLog(episodeId ?? "none", date),
|
||||
enabled: episodeId !== null,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessDayLogDTO | null>(
|
||||
`/api/illness/episodes/${episodeId}/day-logs?date=${date}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateIllness() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
|
||||
}
|
||||
|
||||
/** Create an episode. */
|
||||
export function useCreateEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (input: IllnessEpisodeCreateInput) =>
|
||||
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark an episode recovered. */
|
||||
export function useResolveEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/resolve`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete an episode. */
|
||||
export function useDeleteEpisode() {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<void>(`/api/illness/episodes/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert one day-log on an episode. */
|
||||
export function useUpsertDayLog(episodeId: string) {
|
||||
const invalidate = useInvalidateIllness();
|
||||
return useMutation({
|
||||
mutationFn: (input: IllnessDayLogInput) =>
|
||||
apiPost<IllnessDayLogDTO>(
|
||||
`/api/illness/episodes/${episodeId}/day-logs`,
|
||||
input,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Pill,
|
||||
Settings,
|
||||
Stethoscope,
|
||||
Thermometer,
|
||||
Trophy,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
@@ -97,6 +98,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
tourId: "nav-labs",
|
||||
requiresModule: "labs",
|
||||
},
|
||||
// v1.18.1 — the illness/condition journal sits in the clinical spine
|
||||
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
|
||||
// opt-in `illness` key from the resolved module map, so the entry is
|
||||
// absent until the account turns the module on from the Modules hub.
|
||||
{
|
||||
href: "/illness",
|
||||
tKey: "nav.illness",
|
||||
icon: Thermometer,
|
||||
tourId: "nav-illness",
|
||||
requiresModule: "illness",
|
||||
},
|
||||
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
|
||||
// clinical spine, peer to Labs and Recovery. It is a first-class tracking
|
||||
// surface ("wann muss ich was wo machen"), not pure configuration, so it
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Smile,
|
||||
Sparkles,
|
||||
TestTube,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -66,6 +67,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
workouts: Dumbbell,
|
||||
recovery: Activity,
|
||||
labs: TestTube,
|
||||
illness: Thermometer,
|
||||
achievements: Award,
|
||||
coach: MessageCircleHeart,
|
||||
insights: Sparkles,
|
||||
|
||||
@@ -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