Files
HealthLog/src/app/api/illness/episodes/route.ts
T
Marc-André Bombeck d7a698950c fix(illness): harden the episode + day-log write paths
Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates.
Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial
edit is checked against the stored onset). Run the day-log upsert, its symptom-
link replacement, and the read-back in one transaction and return the full DTO,
dropping the route's second round-trip. Restrict symptom severity to 1-3 (a
link's presence already means present, so 0 is not a distinct state). Return
the standard { deleted: true } envelope from DELETE instead of a bodyless 204.
2026-06-16 23:18:06 +02:00

145 lines
4.7 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, 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 { 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 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));
});
// `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> {
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);
}