mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(illness): wire P3 engine to UI + OpenAPI, correctness (return-anchor, red-flag, fever, tz), FLARE/edit, guards
This commit is contained in:
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 },
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user