diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index ad0f82437..3d5b70de8 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -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); }, ); diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index bcae413c3..e8f38d487 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -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 }, diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index fcf29fa78..3bf355ca8 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 690465643..154e0c880 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -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 { 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); -}); +} diff --git a/src/lib/illness/__tests__/day-log-write.test.ts b/src/lib/illness/__tests__/day-log-write.test.ts index 54910cec7..c30cf81ff 100644 --- a/src/lib/illness/__tests__/day-log-write.test.ts +++ b/src/lib/illness/__tests__/day-log-write.test.ts @@ -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", () => { diff --git a/src/lib/illness/__tests__/episode-window-invariant.test.ts b/src/lib/illness/__tests__/episode-window-invariant.test.ts new file mode 100644 index 000000000..8fcff5eb4 --- /dev/null +++ b/src/lib/illness/__tests__/episode-window-invariant.test.ts @@ -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); + }); +}); diff --git a/src/lib/illness/day-log-write.ts b/src/lib/illness/day-log-write.ts index 3210c199e..82f3060f2 100644 --- a/src/lib/illness/day-log-write.ts +++ b/src/lib/illness/day-log-write.ts @@ -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 { 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 { 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) }; } diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index 5af3fc615..83b61e870 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -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; @@ -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; @@ -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)