mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user