Files
HealthLog/src/app/api/illness/episodes/route.ts
T
Marc-André Bombeck 095aba095e Let a delegate add to the clinical record of a shared account
An allergy, a family-history entry, an illness episode and a custom-metric
value now resolve the record rather than the caller.

Two of them carry a consequence worth naming. The family-history row
describes the record's relatives, so it is the one admitted write where
third-party health information is present by design; it is stored as the
record's own, exactly as the read arm already serves it. The illness module
gate runs against the record before the write, so a delegate cannot open an
episode inside a record whose owner switched the module off, whatever they
set on their own account.

The custom-metric entry reaches its metric through the ownership hop, which
now resolves against the record: a delegate can only feed a definition the
owner made, and their own metric of the same name is unreachable from in
there. Its validation breadcrumb moves to auditLog().
2026-08-03 12:10:59 +02:00

149 lines
5.1 KiB
TypeScript

/**
* `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, requireRecordAuth } 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 { withIdempotency } from "@/lib/idempotency";
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 requireRecordAuth("read");
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));
});
// `withIdempotency` lets an iOS retry / double-tap re-send the same
// `Idempotency-Key` without minting a duplicate episode (siblings — labs,
// measurements — wrap their creates the same way).
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEpisode));
async function postEpisode(request: NextRequest): Promise<Response> {
// v1.36.x — a delegated write. The module gate below follows the RECORD, not
// the caller: whether illness tracking exists is a property of the record, so
// a delegate who runs the module on their own account does not thereby gain
// an episode surface inside somebody else's.
const { user } = await requireRecordAuth("write");
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);
}