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:
Marc-André Bombeck committed 2026-07-24 17:31:34 +02:00
1 parent 5c581e9e58
commit 8849f2cecd
7 files changed
+345 -143

No files matched your search

+34 -6
View File
@@ -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 });
});
+32 -5
View File
@@ -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),
+36 -6
View File
@@ -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),
);
});
+78 -25
View File
@@ -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
+10 -1
View File
@@ -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> {