mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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);
|
||||
});
|
||||
Reference in new issue
Block a user