mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(concurrency): guard the prefs writes and serialise inventory + privacy races
Apply the shared optimistic-concurrency guard (takeBaseToken + guardedUserUpdate) to the notification-prefs, mood-tag-layout, modules, and coach-prefs write endpoints so an interleaved edit can no longer silently overwrite a category a fresher writer just set; a tokenless request keeps the prior unconditional write. Route the inventory stock-correction PATCH and DELETE through the same per-medication advisory lock intake consumption takes, so an absolute stock write can no longer clobber a concurrent dose decrement. Guard every insight cache commit on the privacy mode still being what the generation read, so a privacy-mode change is respected by a generation that was already running.
This commit is contained in:
1 parent
5c581e9e58
commit
8849f2cecd
7 files changed
+345
-143
No files matched your search
@@ -19,6 +19,11 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
coachPrefsSchema,
|
||||
parseCoachPrefs,
|
||||
@@ -32,9 +37,14 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { coachPrefsJson: true },
|
||||
select: { coachPrefsJson: true, updatedAt: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
...parseCoachPrefs(row?.coachPrefsJson),
|
||||
// v1.32.22 (M4) — optimistic-concurrency token (additive sibling key; the
|
||||
// prefs shape is a fixed key set, so `updatedAt` cannot collide).
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
});
|
||||
return apiSuccess(parseCoachPrefs(row?.coachPrefsJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
@@ -46,7 +56,15 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(422, "coach-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = coachPrefsSchema.safeParse(body ?? {});
|
||||
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
|
||||
// parse. This is a full-replace endpoint, so the classic two-tab / two-device
|
||||
// stale-replace clobbers silently: the form was hydrated from a GET minutes
|
||||
// ago. The token closes that window.
|
||||
const taken = takeBaseToken(body ?? {});
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = coachPrefsSchema.safeParse(taken.rest ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.coach-prefs.put.invalid" },
|
||||
@@ -63,13 +81,23 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
// Coach snapshot share a single source of truth. Keeps the user's
|
||||
// privacy contract symmetric across the two surfaces without
|
||||
// requiring a separate Insights settings sheet.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// v1.32.22 (M4) — the guarded write covers BOTH columns in one conditional
|
||||
// update so the mirror-column contract (v1.4.36 W3 T3) stays unsplittable: a
|
||||
// 409 leaves `coachPrefsJson` AND `insightsExcludeMetrics` untouched together.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: {
|
||||
coachPrefsJson: parsed.data,
|
||||
insightsExcludeMetrics: parsed.data.excludeMetrics,
|
||||
},
|
||||
conflict: {
|
||||
action: "auth.me.coach-prefs.conflict",
|
||||
errorCode: "coach_prefs_conflict",
|
||||
message: "Coach preferences changed since they were loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.coach-prefs.put" },
|
||||
@@ -83,5 +111,5 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
clusterCount: parsed.data.dataClusters?.length ?? null,
|
||||
},
|
||||
});
|
||||
return apiSuccess(parsed.data);
|
||||
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
|
||||
});
|
||||
@@ -36,6 +36,11 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
|
||||
@@ -49,7 +54,13 @@ export const GET = apiHandler(async () => {
|
||||
annotate({ action: { name: "auth.me.modules.get" } });
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
return apiSuccess({ modules });
|
||||
// v1.32.22 (M3) — surface the optimistic-concurrency token. `resolveModuleMap`
|
||||
// returns no row metadata, so read `updatedAt` on its own.
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return apiSuccess({ modules, updatedAt: row?.updatedAt?.toISOString() });
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
@@ -75,7 +86,14 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(422, "modules.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = modulePrefsPatchSchema.safeParse(body ?? {});
|
||||
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
|
||||
// parse: `modulePrefsPatchSchema` is `.strict()` and must stay strict, so
|
||||
// the transport token can never reach it (it would 422 as an unknown key).
|
||||
const taken = takeBaseToken(body ?? {});
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = modulePrefsPatchSchema.safeParse(taken.rest ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.modules.patch.invalid_shape" },
|
||||
@@ -105,10 +123,19 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
changed.push(key);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// v1.32.22 (M3) — guard the write on the client's base token; a stale base
|
||||
// 409s and writes nothing. Tokenless requests keep the unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { modulePreferencesJson: merged },
|
||||
conflict: {
|
||||
action: "auth.me.modules.conflict",
|
||||
errorCode: "modules_conflict",
|
||||
message: "Module preferences changed since they were loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
|
||||
@@ -123,5 +150,5 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
meta: { changed },
|
||||
});
|
||||
|
||||
return apiSuccess({ modules });
|
||||
return apiSuccess({ modules, updatedAt: guarded.updatedAt });
|
||||
});
|
||||
@@ -34,6 +34,11 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
notificationPrefsSchema,
|
||||
parseNotificationPrefs,
|
||||
@@ -51,9 +56,14 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
// v1.32.22 (M1) — `updatedAt` rides the response as the optimistic-
|
||||
// concurrency token so a client can echo it on its next PATCH.
|
||||
select: { notificationPrefs: true, updatedAt: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
...parseNotificationPrefs(row?.notificationPrefs ?? null),
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
});
|
||||
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
@@ -79,7 +89,17 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(422, "notification-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = notificationPrefsSchema.safeParse(body ?? {});
|
||||
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse so it never pollutes `changedCategories`
|
||||
// (`Object.keys(parsed.data)`) or the schema. The race that matters: iOS
|
||||
// sets `medication.clientManaged = true`, a web card PATCH based on a
|
||||
// pre-iOS read deep-merges the whole blob WITHOUT it, silently reverting the
|
||||
// flag (the double-reminder class). With the token, that stale write 409s.
|
||||
const taken = takeBaseToken(body ?? {});
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = notificationPrefsSchema.safeParse(taken.rest ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
|
||||
@@ -103,10 +123,21 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// v1.32.22 (M1) — guard the deep-merge write on the client's base token. A
|
||||
// stale base 409s and writes NOTHING, so a concurrent web PATCH can't revert
|
||||
// a category (e.g. `medication.clientManaged`) another writer just set from a
|
||||
// fresher read. A tokenless request keeps the prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { notificationPrefs: toJson(merged) },
|
||||
conflict: {
|
||||
action: "user.notification_prefs.conflict",
|
||||
errorCode: "notification_prefs_conflict",
|
||||
message: "Notification preferences changed since they were loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
// Capture which categories the PATCH actually touched so the audit
|
||||
// trail records the user's intent, not just the full resolved row.
|
||||
@@ -130,5 +161,5 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(merged);
|
||||
return apiSuccess({ ...merged, updatedAt: guarded.updatedAt });
|
||||
});
|
||||
@@ -36,33 +36,17 @@ import {
|
||||
computeInventoryState,
|
||||
} from "@/lib/medications/inventory/state-machine";
|
||||
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
|
||||
import { lockMedicationInventory } from "@/lib/medications/inventory/consumption";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
async function loadOwnedItem(
|
||||
medicationId: string,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const item = await prisma.medicationInventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const existing = await loadOwnedItem(id, itemId, user.id);
|
||||
if (!existing) return apiError("Inventory item not found", 404);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
@@ -84,67 +68,6 @@ export const PATCH = apiHandler(
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
// Compose the next-row shape. Each mutation field is optional and
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
// v1.16.12 — Decimal column; work in JS numbers locally (the
|
||||
// arithmetic below stays well within double precision) and let
|
||||
// Prisma re-widen on write.
|
||||
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
// The clock can be set manually if the user opened the pen
|
||||
// without logging an intake event.
|
||||
nextFirstUseAt = markAsFirstUseAt;
|
||||
if (nextState === "ACTIVE") nextState = "IN_USE";
|
||||
}
|
||||
|
||||
if (printedExpiry !== undefined) {
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (unitsRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// The wire field counts UNITS. Clamp to the item's capacity; the
|
||||
// state-machine re-run below owns every state consequence (0 ⇒
|
||||
// USED_UP, a raise out of 0 re-evaluates against the expiry
|
||||
// clocks).
|
||||
nextUnitsRemaining = Math.min(
|
||||
unitsRemaining,
|
||||
Number(existing.unitsTotal),
|
||||
);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
nextUnitsRemaining = 0;
|
||||
nextState = "USED_UP";
|
||||
}
|
||||
|
||||
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
|
||||
|
||||
// Re-run the canonical state machine over the composed next-row
|
||||
// view. The clause-by-clause updates above set the state ad-hoc
|
||||
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
|
||||
// but a back-dated `markAsFirstUseAt` whose 30-day window already
|
||||
// elapsed should land at EXPIRED, not IN_USE. The state machine is
|
||||
// pure and idempotent — running it once more with the composed view
|
||||
// collapses every edge case onto the same decision tree the intake
|
||||
// hook and the daily expire cron already share. USED_UP is terminal
|
||||
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// override remains sticky.
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Only touch the note columns when the caller supplied a value; an absent
|
||||
// `notes` leaves the existing ciphertext untouched. When present, encrypt
|
||||
// at rest and null the plaintext column.
|
||||
@@ -163,26 +86,114 @@ export const PATCH = apiHandler(
|
||||
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
|
||||
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
|
||||
|
||||
const updated = await prisma.medicationInventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
state: nextState,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
...labellingData,
|
||||
...notesData,
|
||||
},
|
||||
// v1.32.22 (M5) — the stock correction writes an ABSOLUTE `unitsRemaining`
|
||||
// composed from a prior read. `consumeForIntake` decrements the same rows
|
||||
// under a per-medication advisory lock; without taking that SAME lock the
|
||||
// correction's read-compose-write could clobber a concurrent decrement (or
|
||||
// vice versa). Route the whole read-compose-write through one transaction
|
||||
// that takes the lock first and re-reads INSIDE it, so the correction
|
||||
// serializes against intake consumption instead of racing it.
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await lockMedicationInventory(tx, id);
|
||||
|
||||
const existing = await tx.medicationInventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.medicationId !== id
|
||||
) {
|
||||
return { notFound: true as const };
|
||||
}
|
||||
|
||||
// Compose the next-row shape. Each mutation field is optional and
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
// v1.16.12 — Decimal column; work in JS numbers locally (the
|
||||
// arithmetic below stays well within double precision) and let
|
||||
// Prisma re-widen on write.
|
||||
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
// The clock can be set manually if the user opened the pen
|
||||
// without logging an intake event.
|
||||
nextFirstUseAt = markAsFirstUseAt;
|
||||
if (nextState === "ACTIVE") nextState = "IN_USE";
|
||||
}
|
||||
|
||||
if (printedExpiry !== undefined) {
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (unitsRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// The wire field counts UNITS. Clamp to the item's capacity; the
|
||||
// state-machine re-run below owns every state consequence (0 ⇒
|
||||
// USED_UP, a raise out of 0 re-evaluates against the expiry
|
||||
// clocks).
|
||||
nextUnitsRemaining = Math.min(
|
||||
unitsRemaining,
|
||||
Number(existing.unitsTotal),
|
||||
);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
nextUnitsRemaining = 0;
|
||||
nextState = "USED_UP";
|
||||
}
|
||||
|
||||
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
|
||||
|
||||
// Re-run the canonical state machine over the composed next-row
|
||||
// view. The clause-by-clause updates above set the state ad-hoc
|
||||
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
|
||||
// but a back-dated `markAsFirstUseAt` whose 30-day window already
|
||||
// elapsed should land at EXPIRED, not IN_USE. The state machine is
|
||||
// pure and idempotent — running it once more with the composed view
|
||||
// collapses every edge case onto the same decision tree the intake
|
||||
// hook and the daily expire cron already share. USED_UP is terminal
|
||||
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// override remains sticky.
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const updated = await tx.medicationInventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
state: nextState,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
...labellingData,
|
||||
...notesData,
|
||||
},
|
||||
});
|
||||
|
||||
return { prevState: existing.state, updated };
|
||||
});
|
||||
|
||||
if ("notFound" in outcome) return apiError("Inventory item not found", 404);
|
||||
const { prevState, updated } = outcome;
|
||||
|
||||
await auditLog("medication.inventory.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId,
|
||||
prevState: existing.state,
|
||||
prevState,
|
||||
nextState: updated.state,
|
||||
},
|
||||
});
|
||||
@@ -193,7 +204,7 @@ export const PATCH = apiHandler(
|
||||
entity_type: "inventory_item",
|
||||
entity_id: itemId,
|
||||
},
|
||||
meta: { medication_id: id, prev: existing.state, next: updated.state },
|
||||
meta: { medication_id: id, prev: prevState, next: updated.state },
|
||||
});
|
||||
|
||||
// A stock correction / first-use / used-up flip changes the
|
||||
@@ -211,11 +222,24 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const existing = await loadOwnedItem(id, itemId, user.id);
|
||||
// v1.32.22 (M5) — take the same per-medication advisory lock the intake
|
||||
// consumption hook takes, so a delete that interleaves a `consumeForIntake`
|
||||
// serializes against it: the consume either fully applies before the row
|
||||
// vanishes or blocks until the delete commits and then finds nothing to
|
||||
// decrement, instead of half-applying against a row deleted mid-flight.
|
||||
const existing = await prisma.$transaction(async (tx) => {
|
||||
await lockMedicationInventory(tx, id);
|
||||
const item = await tx.medicationInventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
if (!item || item.userId !== user.id || item.medicationId !== id) {
|
||||
return null;
|
||||
}
|
||||
await tx.medicationInventoryItem.delete({ where: { id: itemId } });
|
||||
return item;
|
||||
});
|
||||
if (!existing) return apiError("Inventory item not found", 404);
|
||||
|
||||
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
|
||||
|
||||
await auditLog("medication.inventory.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -3,6 +3,11 @@ import { NextRequest } from "next/server";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
moodTagLayoutSchema,
|
||||
@@ -33,9 +38,11 @@ export const dynamic = "force-dynamic";
|
||||
async function resolveLayoutResponse(
|
||||
userId: string,
|
||||
layout: MoodTagLayout,
|
||||
updatedAt?: string,
|
||||
): Promise<{
|
||||
groupOrder: string[];
|
||||
placements: Record<string, string[]>;
|
||||
updatedAt?: string;
|
||||
}> {
|
||||
const categories = await prisma.moodTagCategory.findMany({
|
||||
where: { isActive: true, OR: [{ userId: null }, { userId }] },
|
||||
@@ -48,6 +55,9 @@ async function resolveLayoutResponse(
|
||||
layout.groupOrder,
|
||||
),
|
||||
placements: layout.placements ?? {},
|
||||
// v1.32.22 (M2) — optimistic-concurrency token; omitted (dropped from
|
||||
// JSON) when absent so a fresh user's response shape is unchanged.
|
||||
...(updatedAt !== undefined ? { updatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,10 +65,12 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { moodTagLayoutJson: true },
|
||||
select: { moodTagLayoutJson: true, updatedAt: true },
|
||||
});
|
||||
const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson);
|
||||
return apiSuccess(await resolveLayoutResponse(user.id, stored));
|
||||
return apiSuccess(
|
||||
await resolveLayoutResponse(user.id, stored, row?.updatedAt?.toISOString()),
|
||||
);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
@@ -68,7 +80,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = moodTagLayoutSchema.safeParse(rawJsonBody);
|
||||
|
||||
// v1.32.22 (M2) — split the optimistic-concurrency base token off before the
|
||||
// Zod parse. The two cards on this page write DIFFERENT fields of the same
|
||||
// blob (group order vs placements); a stale merge would resurrect the other
|
||||
// card's overwritten field, which the token now 409s instead.
|
||||
const taken = takeBaseToken(rawJsonBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = moodTagLayoutSchema.safeParse(taken.rest);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe
|
||||
@@ -88,10 +109,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
: {}),
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { moodTagLayoutJson: toJson(merged) },
|
||||
conflict: {
|
||||
action: "mood.tag.layout.conflict",
|
||||
errorCode: "mood_tag_layout_conflict",
|
||||
message: "Mood-tag layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.tag.layout.update" },
|
||||
@@ -101,5 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(await resolveLayoutResponse(user.id, merged));
|
||||
return apiSuccess(
|
||||
await resolveLayoutResponse(user.id, merged, guarded.updatedAt),
|
||||
);
|
||||
});
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
resolveDashboardLayout,
|
||||
type ComparisonBaseline,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
|
||||
@@ -132,7 +132,19 @@ export type GenerateOutcome =
|
||||
* than with `failed` on purpose: there is no upstream fault to retry
|
||||
* against, so the caller must not enqueue the provider-failure retry.
|
||||
*/
|
||||
| { status: "skipped"; reason: "no-provider" | "no-consent" | "budget" }
|
||||
/**
|
||||
* v1.32.22 (M6) — a privacy-mode flip landed WHILE this generation was in
|
||||
* flight. The generation read the old scope at snapshot time; committing its
|
||||
* output would re-populate the cache under a scope the user just changed
|
||||
* away from. Each cache write is guarded on `insightsPrivacyMode` still being
|
||||
* what it was read as; a zero-row match returns this skip and writes nothing.
|
||||
* The flip already nulled the cache, so the next scheduled/explicit run
|
||||
* regenerates under the new scope — no warm-on-mount.
|
||||
*/
|
||||
| {
|
||||
status: "skipped";
|
||||
reason: "no-provider" | "no-consent" | "budget" | "privacy-mode-changed";
|
||||
}
|
||||
| { status: "failed"; reason: string };
|
||||
|
||||
/** Higher, seedless temperature for the daily-briefing phrasing re-roll. */
|
||||
@@ -492,6 +504,36 @@ function failBeforeProvider(
|
||||
return { status: "failed", reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.22 (M6) — commit a cache write ONLY while the privacy mode is still
|
||||
* what this generation read. Returns `true` when the guarded update matched
|
||||
* the row, `false` when a concurrent `PUT /api/insights/settings` flip
|
||||
* advanced `insightsPrivacyMode` since the read — in which case the caller
|
||||
* must abandon the commit (the flip already nulled the cache). Guards on the
|
||||
* raw column value including `null`, so a null → "aggregated" explicit write
|
||||
* also invalidates an in-flight generation correctly.
|
||||
*/
|
||||
async function commitInsightUnderMode(
|
||||
userId: string,
|
||||
modeAtRead: string,
|
||||
data: Prisma.UserUpdateInput,
|
||||
): Promise<boolean> {
|
||||
const res = await prisma.user.updateMany({
|
||||
where: { id: userId, insightsPrivacyMode: modeAtRead },
|
||||
data,
|
||||
});
|
||||
return res.count > 0;
|
||||
}
|
||||
|
||||
/** v1.32.22 (M6) — the shared skip outcome when a privacy flip beat the commit. */
|
||||
function privacyModeChangedSkip(locale: SupportedLocale): GenerateOutcome {
|
||||
annotate({
|
||||
action: { name: "insights.generate.privacy_mode_changed" },
|
||||
meta: { locale },
|
||||
});
|
||||
return { status: "skipped", reason: "privacy-mode-changed" };
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
/** Resolved UI locale for the prompt + cache row. */
|
||||
locale: SupportedLocale;
|
||||
@@ -592,6 +634,13 @@ export async function generateComprehensiveInsight(
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
// v1.32.22 (M6) — capture the privacy-mode column value read here (a
|
||||
// non-nullable String defaulting to "aggregated"), so every cache commit
|
||||
// below can guard on it. A `PUT /api/insights/settings` flip that lands
|
||||
// between this read and a commit advances the column ("aggregated" ⇄ "raw");
|
||||
// the guarded write then matches zero rows and the generation is skipped
|
||||
// rather than stamping an old-scope payload over the cache the flip nulled.
|
||||
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
|
||||
// v1.18.11 P1 — bound the briefing's bulk feature read to a recent window
|
||||
// (all-time extremes are sourced separately, see features.ts). The downgrade
|
||||
// ladder below stays as the safety net for the rare oversize payload.
|
||||
@@ -788,14 +837,15 @@ export async function generateComprehensiveInsight(
|
||||
effectiveTimeoutMs,
|
||||
});
|
||||
if (rerolled) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: rerolled.text,
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
},
|
||||
// v1.32.22 (M6) — the reroll rewrites `insightsCachedText` derived
|
||||
// from the OLD-scope cache; refuse it if a privacy flip landed since
|
||||
// the read.
|
||||
const committed = await commitInsightUnderMode(userId, modeAtRead, {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: rerolled.text,
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
});
|
||||
if (!committed) return privacyModeChangedSkip(locale);
|
||||
invalidateUserInsights(userId);
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefing_rerolled" },
|
||||
@@ -805,23 +855,24 @@ export async function generateComprehensiveInsight(
|
||||
}
|
||||
// A re-roll miss still stamps the day so a persistently failing
|
||||
// provider cannot be re-driven on every page-open; the next day retries.
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
},
|
||||
// v1.32.22 (M6) — even a timestamp-only stamp would resurrect a "fresh
|
||||
// insight" claim over a cache a privacy flip deliberately nulled, so it
|
||||
// is guarded too.
|
||||
const stamped = await commitInsightUnderMode(userId, modeAtRead, {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
});
|
||||
if (!stamped) return privacyModeChangedSkip(locale);
|
||||
annotate({
|
||||
action: { name: "insights.generate.skipped_unchanged" },
|
||||
meta: { locale, rerollAttempted: true },
|
||||
});
|
||||
return { status: "unchanged" };
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { insightsCachedAt: new Date() },
|
||||
const stamped = await commitInsightUnderMode(userId, modeAtRead, {
|
||||
insightsCachedAt: new Date(),
|
||||
});
|
||||
if (!stamped) return privacyModeChangedSkip(locale);
|
||||
annotate({
|
||||
action: { name: "insights.generate.skipped_unchanged" },
|
||||
meta: { locale },
|
||||
@@ -1145,14 +1196,16 @@ export async function generateComprehensiveInsight(
|
||||
return failBeforeProvider(userId, locale, "aborted");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsSnapshotHash: snapshotHash,
|
||||
},
|
||||
// v1.32.22 (M6) — the payload was built under the scope read at the top of
|
||||
// this function. If a privacy flip landed since, committing it would stamp an
|
||||
// old-scope briefing over the null the flip wrote — guard the write on the
|
||||
// mode being unchanged and skip when it advanced.
|
||||
const committed = await commitInsightUnderMode(userId, modeAtRead, {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsSnapshotHash: snapshotHash,
|
||||
});
|
||||
if (!committed) return privacyModeChangedSkip(locale);
|
||||
// v1.16.8 — no blanket per-status eviction here any more. The cards
|
||||
// track their own data through the ingest invalidator and their own
|
||||
// content-hash gates; a fresh comprehensive briefing does not change
|
||||
|
||||
@@ -76,7 +76,16 @@ async function runAtomically<T>(
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
async function lockMedicationInventory(
|
||||
/**
|
||||
* Serialize every write against one medication's inventory on a
|
||||
* transaction-scoped Postgres advisory lock. Intake consumption already
|
||||
* takes it; the stock-correction PATCH / DELETE route it through the SAME
|
||||
* key (v1.32.22, M5) so an absolute stock write can no longer race a
|
||||
* concurrent dose decrement and clobber it. Exported because the key must
|
||||
* be shared verbatim — a re-derived hash elsewhere would be a different
|
||||
* lock, i.e. no lock at all.
|
||||
*/
|
||||
export async function lockMedicationInventory(
|
||||
tx: TxClient,
|
||||
medicationId: string,
|
||||
): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user