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