From 8849f2cecd74d25672692ad099c962468bf62324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 17:31:34 +0200 Subject: [PATCH] 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. --- src/app/api/auth/me/coach-prefs/route.ts | 40 +++- src/app/api/auth/me/modules/route.ts | 37 ++- .../api/auth/me/notification-prefs/route.ts | 43 +++- .../[id]/inventory/[itemId]/route.ts | 212 ++++++++++-------- src/app/api/mood/tags/layout/route.ts | 42 +++- src/lib/insights/comprehensive-generate.ts | 103 ++++++--- src/lib/medications/inventory/consumption.ts | 11 +- 7 files changed, 345 insertions(+), 143 deletions(-) diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 75a60780e..e40ff6c13 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -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 }); }); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index b5f9080ec..637876743 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -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 }); }); diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts index 47e21c24f..b7e64cfd3 100644 --- a/src/app/api/auth/me/notification-prefs/route.ts +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -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 }); }); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 449ce8bfd..a31eae9ec 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -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), diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts index 3848c2dbc..2e1ea0464 100644 --- a/src/app/api/mood/tags/layout/route.ts +++ b/src/app/api/mood/tags/layout/route.ts @@ -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; + 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), + ); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index a816cb463..0b41002f0 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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 { + 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 diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts index 5b240b7fc..913b02b1c 100644 --- a/src/lib/medications/inventory/consumption.ts +++ b/src/lib/medications/inventory/consumption.ts @@ -76,7 +76,16 @@ async function runAtomically( 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 {