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);
});
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* v1.18.1 — per-episode detail page (`/illness/[id]`), mirroring
* `/labs/[biomarkerId]`. Born-gated on the resolved `modules.illness` flag;
* an unauthenticated visitor is bounced to login, an account without the
* module opted in is bounced home (every `/api/illness/*` route also enforces
* the gate server-side, so this is a UX redirect, not the security boundary).
*/
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
export default function IllnessEpisodePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { user, isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, enabled, router]);
if (!mounted || isLoading || !isAuthenticated || !enabled) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<div className="space-y-6">
<BackLink href="/illness" label={t("illness.backToList")} />
<IllnessEpisodeDetail episodeId={id} />
</div>
);
}