fix(illness): wire P3 engine to UI + OpenAPI, correctness (return-anchor, red-flag, fever, tz), FLARE/edit, guards

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:18:40 +02:00
commit d92af094ed
32 files changed
+2379 -272

No files matched your search

@@ -115,11 +115,6 @@ export const POST = apiHandler(
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),
@@ -135,6 +130,6 @@ export const POST = apiHandler(
meta: { existed: result.existed },
});
return apiSuccess(toIllnessDayLogDTO(row), result.existed ? 200 : 201);
return apiSuccess(result.dto, result.existed ? 200 : 201);
},
);
@@ -36,7 +36,13 @@ export const PATCH = apiHandler(
const { id } = await params;
const existing = await prisma.illnessEpisode.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true, lifecycle: true },
select: {
id: true,
userId: true,
deletedAt: true,
lifecycle: true,
onsetAt: true,
},
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
return apiError("Episode not found", 404);
@@ -63,6 +69,14 @@ export const PATCH = apiHandler(
? new Date(parsed.data.resolvedAt)
: new Date();
// An episode can never resolve before it began (the inverted-window guard,
// checked against the stored onset since the body only carries resolvedAt).
if (resolvedAt < existing.onsetAt) {
return apiError("resolvedAt must be on or after onsetAt", 422, {
errorCode: "illness.episode.invalid-window",
});
}
const updated = await prisma.illnessEpisode.update({
where: { id },
data: { resolvedAt },
+29 -3
View File
@@ -1,7 +1,8 @@
/**
* `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).
* `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent;
* returns `{ deleted: true }`).
*
* 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.
@@ -62,7 +63,13 @@ export const PATCH = apiHandler(
const { id } = await params;
const existing = await prisma.illnessEpisode.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
select: {
id: true,
userId: true,
deletedAt: true,
onsetAt: true,
resolvedAt: true,
},
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
return apiError("Episode not found", 404);
@@ -81,6 +88,23 @@ export const PATCH = apiHandler(
}
const entry = parsed.data;
// Window invariant against the MERGED state: a partial edit touching only
// one instant is validated against the stored value (the schema already
// catches a both-in-body inversion).
const mergedOnset =
entry.onsetAt !== undefined ? new Date(entry.onsetAt) : existing.onsetAt;
const mergedResolved =
entry.resolvedAt !== undefined
? entry.resolvedAt
? new Date(entry.resolvedAt)
: null
: existing.resolvedAt;
if (mergedResolved && mergedResolved < mergedOnset) {
return apiError("resolvedAt must be on or after onsetAt", 422, {
errorCode: "illness.episode.invalid-window",
});
}
// A re-parent must point at an owned, live episode.
if (entry.parentConditionId) {
const parent = await prisma.illnessEpisode.findUnique({
@@ -173,6 +197,8 @@ export const DELETE = apiHandler(
},
});
return new Response(null, { status: 204 });
// Standard `{ deleted: true }` envelope (the cross-module DELETE shape) so
// the client never has to special-case a bodyless 204 here.
return apiSuccess({ deleted: true });
},
);
+8 -2
View File
@@ -24,6 +24,7 @@ import {
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,
@@ -67,7 +68,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(rows.map(toIllnessEpisodeDTO));
});
export const POST = apiHandler(async (request: NextRequest) => {
// `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);
@@ -135,4 +141,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiSuccess(toIllnessEpisodeDTO(created), 201);
});
}