fix(illness): harden the episode + day-log write paths

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:18:06 +02:00
1 parent 82cabff8d0
commit d7a698950c
8 files changed
+266 -81

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);
});
}
@@ -8,11 +8,21 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
illnessSymptom: { findMany: vi.fn() },
illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
illnessDayLog: { findUnique: vi.fn(), upsert: vi.fn() },
}));
const db = vi.hoisted(() => {
const client = {
illnessSymptom: { findMany: vi.fn() },
illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
illnessDayLog: {
findUnique: vi.fn(),
upsert: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
// The upsert helper runs in one interactive transaction; the mock just
// hands the same client through as `tx`.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(client)),
};
return client;
});
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
@@ -30,7 +40,20 @@ import type { IllnessDayLogInput } from "@/lib/validations/illness";
beforeEach(() => {
vi.clearAllMocks();
db.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) =>
fn(db),
);
db.illnessDayLog.upsert.mockResolvedValue({ id: "dl-1" });
db.illnessDayLog.findUniqueOrThrow.mockResolvedValue({
id: "dl-1",
episodeId: "ep1",
date: "2026-06-16",
functionalImpact: null,
feverC: null,
noteEncrypted: null,
updatedAt: new Date("2026-06-16T12:00:00Z"),
symptomLinks: [],
});
});
describe("resolveIllnessSymptomIds", () => {
@@ -0,0 +1,67 @@
/**
* v1.18.1 — the `resolvedAt >= onsetAt` window invariant on the illness
* episode schemas. An episode can never resolve before it began; the schema
* `superRefine` rejects an inverted window when both instants are supplied
* (a partial edit touching only one is checked against the stored row in the
* route).
*/
import { describe, expect, it } from "vitest";
import {
illnessEpisodeCreateSchema,
illnessEpisodeUpdateSchema,
} from "@/lib/validations/illness";
const ONSET = "2026-01-10T08:00:00Z";
const BEFORE = "2026-01-08T08:00:00Z";
const AFTER = "2026-01-15T08:00:00Z";
describe("illnessEpisodeCreateSchema — window invariant", () => {
it("rejects resolvedAt earlier than onsetAt", () => {
const out = illnessEpisodeCreateSchema.safeParse({
label: "Cold",
type: "INFECTION",
onsetAt: ONSET,
resolvedAt: BEFORE,
});
expect(out.success).toBe(false);
if (!out.success) {
expect(out.error.issues.some((i) => i.path.includes("resolvedAt"))).toBe(
true,
);
}
});
it("accepts resolvedAt on/after onsetAt", () => {
const out = illnessEpisodeCreateSchema.safeParse({
label: "Cold",
type: "INFECTION",
onsetAt: ONSET,
resolvedAt: AFTER,
});
expect(out.success).toBe(true);
});
it("accepts a create with only onsetAt (no resolvedAt)", () => {
const out = illnessEpisodeCreateSchema.safeParse({
label: "Cold",
type: "INFECTION",
onsetAt: ONSET,
});
expect(out.success).toBe(true);
});
});
describe("illnessEpisodeUpdateSchema — window invariant", () => {
it("rejects a both-in-body inverted window", () => {
const out = illnessEpisodeUpdateSchema.safeParse({
onsetAt: ONSET,
resolvedAt: BEFORE,
});
expect(out.success).toBe(false);
});
it("accepts a partial edit touching only resolvedAt (route merges vs stored)", () => {
const out = illnessEpisodeUpdateSchema.safeParse({ resolvedAt: AFTER });
expect(out.success).toBe(true);
});
});
+79 -53
View File
@@ -20,10 +20,15 @@ import {
encryptToBytes,
} from "@/lib/ai/coach/bytes-codec";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
import { dayLogSymptomInclude, toIllnessDayLogDTO } from "@/lib/illness/dto";
import type { IllnessDayLogDTO } from "@/lib/illness/dto";
import type { Prisma } from "@/generated/prisma/client";
export interface IllnessDayLogWriteResult {
id: string;
existed: boolean;
/** The full DTO of the written row — no second round-trip needed. */
dto: IllnessDayLogDTO;
}
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
@@ -32,16 +37,20 @@ export interface IllnessSymptomSelection {
severity?: number | null;
}
/** A Prisma client OR an interactive-transaction client. */
type Db = typeof prisma | Prisma.TransactionClient;
/**
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown / inactive
* keys silently.
*/
export async function resolveIllnessSymptomIds(
keys: readonly string[],
db: Db = prisma,
): Promise<{ key: string; id: string }[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
const rows = await prisma.illnessSymptom.findMany({
const rows = await db.illnessSymptom.findMany({
where: { key: { in: unique }, isActive: true },
select: { id: true, key: true },
});
@@ -50,14 +59,18 @@ export async function resolveIllnessSymptomIds(
/**
* Replace the symptom-link set for a day-log. Each selection's optional
* 0–3 severity is persisted on the link (NULL = a plain presence link).
* 1–3 severity is persisted on the link (NULL = a plain presence link).
* Runs on the caller's client so it can join the day-log upsert in one
* transaction (no torn state between the delete + create halves).
*/
export async function replaceIllnessSymptomLinks(
dayLogId: string,
selections: readonly IllnessSymptomSelection[],
db: Db = prisma,
): Promise<void> {
const resolved = await resolveIllnessSymptomIds(
selections.map((s) => s.key),
db,
);
const severityByKey = new Map(
selections.map((s) => [
@@ -65,9 +78,9 @@ export async function replaceIllnessSymptomLinks(
typeof s.severity === "number" ? s.severity : null,
]),
);
await prisma.illnessSymptomLink.deleteMany({ where: { dayLogId } });
await db.illnessSymptomLink.deleteMany({ where: { dayLogId } });
if (resolved.length > 0) {
await prisma.illnessSymptomLink.createMany({
await db.illnessSymptomLink.createMany({
data: resolved.map(({ key, id }) => ({
dayLogId,
symptomId: id,
@@ -101,55 +114,68 @@ export async function upsertIllnessDayLog(
): Promise<IllnessDayLogWriteResult> {
const where = { episodeId_date: { episodeId, date: entry.date } };
const existing = await prisma.illnessDayLog.findUnique({
where,
select: {
id: true,
functionalImpact: true,
feverC: true,
noteEncrypted: true,
},
// The upsert, the symptom-link replacement, and the final include-read all
// run in ONE interactive transaction: no torn state between the link delete
// + create halves, and no second round-trip — the written row (with its
// flattened symptom links) comes straight back as the DTO.
const { row, existed } = await prisma.$transaction(async (tx) => {
const existing = await tx.illnessDayLog.findUnique({
where,
select: {
id: true,
functionalImpact: true,
feverC: true,
noteEncrypted: true,
},
});
// Partial merge against the stored row.
const functionalImpact =
entry.functionalImpact !== undefined
? (entry.functionalImpact ?? null)
: (existing?.functionalImpact ?? null);
const feverC =
entry.feverC !== undefined
? (entry.feverC ?? null)
: (existing?.feverC ?? null);
const incomingNote =
entry.note !== undefined
? (entry.note ?? null)
: decryptNoteSoft(existing?.noteEncrypted ?? null);
const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null;
const upserted = await tx.illnessDayLog.upsert({
where,
create: {
userId,
episodeId,
date: entry.date,
tz,
functionalImpact,
feverC,
noteEncrypted,
},
update: {
functionalImpact,
feverC,
noteEncrypted,
deletedAt: null,
},
select: { id: true },
});
if (entry.symptoms !== undefined) {
await replaceIllnessSymptomLinks(upserted.id, entry.symptoms, tx);
}
const full = await tx.illnessDayLog.findUniqueOrThrow({
where: { id: upserted.id },
include: dayLogSymptomInclude,
});
return { row: full, existed: existing !== null };
});
// Partial merge against the stored row.
const functionalImpact =
entry.functionalImpact !== undefined
? (entry.functionalImpact ?? null)
: (existing?.functionalImpact ?? null);
const feverC =
entry.feverC !== undefined
? (entry.feverC ?? null)
: (existing?.feverC ?? null);
const incomingNote =
entry.note !== undefined
? (entry.note ?? null)
: decryptNoteSoft(existing?.noteEncrypted ?? null);
const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null;
const row = await prisma.illnessDayLog.upsert({
where,
create: {
userId,
episodeId,
date: entry.date,
tz,
functionalImpact,
feverC,
noteEncrypted,
},
update: {
functionalImpact,
feverC,
noteEncrypted,
deletedAt: null,
},
select: { id: true },
});
if (entry.symptoms !== undefined) {
await replaceIllnessSymptomLinks(row.id, entry.symptoms);
}
return { id: row.id, existed: existing !== null };
return { id: row.id, existed, dto: toIllnessDayLogDTO(row) };
}
+39 -11
View File
@@ -57,15 +57,28 @@ export const illnessLifecycleEnum = z.enum([
* at rest. `parentConditionId` threads a FLARE/RECURRING bout under a
* parent condition. `onsetAt` defaults to "now" server-side when omitted.
*/
export const illnessEpisodeCreateSchema = z.object({
label: z.string().min(1).max(120),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum.optional().default("ACUTE"),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
});
export const illnessEpisodeCreateSchema = z
.object({
label: z.string().min(1).max(120),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum.optional().default("ACUTE"),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
// An episode can never resolve before it began. `onsetAt` defaults to "now"
// server-side when omitted, so the invariant only bites when BOTH instants
// are supplied (the inverted-window case).
.superRefine((v, ctx) => {
if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) {
ctx.addIssue({
code: "custom",
path: ["resolvedAt"],
message: "resolvedAt must be on or after onsetAt",
});
}
});
export type IllnessEpisodeCreate = z.infer<typeof illnessEpisodeCreateSchema>;
@@ -83,7 +96,19 @@ export const illnessEpisodeUpdateSchema = z
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
.strict();
.strict()
// When BOTH instants are in the same edit body, enforce the window order at
// parse time. A partial edit touching only one is validated against the
// stored value in the route (it must load the row anyway).
.superRefine((v, ctx) => {
if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) {
ctx.addIssue({
code: "custom",
path: ["resolvedAt"],
message: "resolvedAt must be on or after onsetAt",
});
}
});
export type IllnessEpisodeUpdate = z.infer<typeof illnessEpisodeUpdateSchema>;
@@ -120,7 +145,10 @@ export const illnessDayLogInputSchema = z.object({
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(0).max(3).optional(),
// 1–3 graded intensity (Jackson/WURSS). A link's mere PRESENCE already
// means "present"; `null`/omitted = a plain presence link, so 0 is not
// a distinct state and the selector offers 1–3 to match this contract.
severity: z.number().int().min(1).max(3).optional(),
}),
)
.max(40)