mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
145 lines
4.7 KiB
TypeScript
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);
|
|
}
|