fix(medications): accept an Idempotency-Key on the three replayed creates

The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.

All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.

An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:04:57 +02:00
1 parent 1d372df3a0
commit 6fe1897941
6 files changed
+464 -187

No files matched your search

+109 -97
View File
@@ -37,6 +37,7 @@ import {
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
type RouteParams = { params: Promise<{ id: string }> };
@@ -90,102 +91,113 @@ export const GET = apiHandler(
},
);
/**
* Wrapped in `withIdempotency`: registering a container is replayed out of the
* client's offline outbox under the same `Idempotency-Key`, and a replay after
* a lost success response would otherwise register the same pen twice and
* double the stock the low-stock notification reads.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
// that notification rings the record's own phone.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
//
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
// own allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`medication-inventory:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
// puffs) — v1.16.10 renamed the request field to match the
// response side.
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
unitsTotal,
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
unitsTotal,
containerType: created.containerType,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
// A registered container changes the dose-derived stock the
// medications-list payload carries (`stockUnitsRemaining` /
// `stockDosesRemaining`), which the card and table render. Hard-evict
// the per-user medications + compliance buckets so the supply shows on
// the very next read — a mark-stale would let the `cachedSwr` list
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
},
withIdempotency<[NextRequest, RouteParams]>(postInventoryItem),
);
async function postInventoryItem(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
// that notification rings the record's own phone.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
//
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
// own allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`medication-inventory:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
// puffs) — v1.16.10 renamed the request field to match the
// response side.
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
unitsTotal,
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
unitsTotal,
containerType: created.containerType,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
// A registered container changes the dose-derived stock the
// medications-list payload carries (`stockUnitsRemaining` /
// `stockDosesRemaining`), which the card and table render. Hard-evict
// the per-user medications + compliance buckets so the supply shows on
// the very next read — a mark-stale would let the `cachedSwr` list
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
}
@@ -38,6 +38,7 @@ import {
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
type RouteParams = { params: Promise<{ id: string }> };
@@ -96,93 +97,104 @@ export const GET = apiHandler(
},
);
/**
* Wrapped in `withIdempotency`: the client replays this write out of its
* offline outbox with the same `Idempotency-Key`, and a replay after a lost
* success response must not log the same symptom twice. Nothing else on this
* row is unique, so the wrapper is the only thing that can collapse a retry.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
//
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
// this is the one condition on which delegating this verb was admitted. On
// the record key a delegate could exhaust the owner's allowance and lock
// them out of their own log, and could collect a fresh one by switching to
// another record. On the actor key they burn their own, once, wherever
// they are. `medications/compliance` set the same precedent on the read
// side. `actor.id` equals `user.id` for everyone who has not switched, so
// the bucket a self-writer lands in is byte-identical to before.
const rl = await checkRateLimit(
`medication-side-effect:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(shapeSideEffectNotes(created), 201);
},
withIdempotency<[NextRequest, RouteParams]>(postSideEffect),
);
async function postSideEffect(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
//
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
// this is the one condition on which delegating this verb was admitted. On
// the record key a delegate could exhaust the owner's allowance and lock
// them out of their own log, and could collect a fresh one by switching to
// another record. On the actor key they burn their own, once, wherever
// they are. `medications/compliance` set the same precedent on the read
// side. `actor.id` equals `user.id` for everyone who has not switched, so
// the bucket a self-writer lands in is byte-identical to before.
const rl = await checkRateLimit(
`medication-side-effect:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(shapeSideEffectNotes(created), 201);
}
+12 -2
View File
@@ -23,6 +23,7 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
@@ -103,7 +104,16 @@ async function respondWithExistingMirror(
});
}
export const POST = apiHandler(async (request: NextRequest) => {
/**
* The create is wrapped in `withIdempotency` so a client replaying its offline
* outbox after a lost success response replays the first answer instead of
* minting a second medication. The `(externalSource, externalId)` dedupe below
* only ever covered a MIRRORED create; a manually entered medication carries
* neither field and had nothing to collapse a retry onto.
*/
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMedication));
async function postMedication(request: NextRequest): Promise<Response> {
// v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a
// medication with its whole nested schedule, and can never edit or delete
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
@@ -424,4 +434,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
});
}