mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(concurrency): guard layout and about-me writes against a concurrent overwrite
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581) into a shared server helper (takeBaseToken plus guardedUserUpdate / guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken, isConflict). Retrofit the dashboard-widgets route onto the helper and apply the same guard to the insights layout, medications layout, and coach about-me write endpoints. A request that omits the base token keeps the byte-identical unconditional write, so older clients are unaffected. Document the contract in the OpenAPI registry, including the dashboard-widgets path that was previously absent.
This commit is contained in:
1 parent
df4425babf
commit
ab03bc96f1
27 files changed
+1669
-352
No files matched your search
+771
-209
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"undo": "Rückgängig",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"conflictReloaded": "Reloaded from the server. Please make your change again.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"undo": "Undo",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"undo": "Deshacer",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"undo": "Annuler",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"undo": "Annulla",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Zapisz",
|
||||
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"undo": "Cofnij",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.20",
|
||||
"version": "1.32.21",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -110,12 +111,21 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = aboutMePutSchema.safeParse(body);
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse (issue #581 family). The token guards on
|
||||
// `UserHealthProfile.updatedAt`, which — unlike the layout endpoints on the
|
||||
// shared `User` row — moves only when THIS surface (or the `/adopt`
|
||||
// sub-route) writes, so it is noise-free.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = aboutMePutSchema.safeParse(taken.rest);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
@@ -165,12 +175,53 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
|
||||
// - no base token → today's upsert (backward-compat arm, byte-identical);
|
||||
// - base present + row still carries it → conditional update, echo fresh;
|
||||
// - base present + zero match → the row either vanished (deleted since the
|
||||
// read → create wins, nothing to clobber) or advanced (real conflict →
|
||||
// 409, no write).
|
||||
let updatedAtIso: string;
|
||||
if (base === undefined) {
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = row.updatedAt.toISOString();
|
||||
} else {
|
||||
const guarded = await prisma.userHealthProfile.updateMany({
|
||||
where: { userId: user.id, updatedAt: base },
|
||||
data: update,
|
||||
});
|
||||
if (guarded.count === 0) {
|
||||
const existing = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (existing) {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.conflict" },
|
||||
meta: { base_updated_at: base.toISOString() },
|
||||
});
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
const created = await prisma.userHealthProfile.create({
|
||||
data: { userId: user.id, ...update },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = created.updatedAt.toISOString();
|
||||
} else {
|
||||
const fresh = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Read back the effective state (covers omitted fields) and derive
|
||||
// the clarifying questions. An entirely empty self-context clears
|
||||
@@ -222,7 +273,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
updatedAt: updatedAtIso,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
@@ -16,6 +15,11 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
@@ -152,12 +156,10 @@ const layoutSchema = z.object({
|
||||
.array(z.enum(HERO_RING_IDS))
|
||||
.max(MAX_HERO_RING_ORDER)
|
||||
.optional(),
|
||||
// v1.32.16 (issue #581) — optimistic-concurrency base token. The client
|
||||
// sends the ISO `updatedAt` it read the layout at; the write is then
|
||||
// guarded on the stored row still carrying it (else 409, no write).
|
||||
// Optional so older web builds and the native client keep the prior
|
||||
// unconditional write. Not a layout field — stripped before serialize.
|
||||
baseUpdatedAt: z.string().optional(),
|
||||
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
|
||||
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
|
||||
// which strips it BEFORE this parse: the token is transport, not a layout
|
||||
// field, and pulling it pre-Zod keeps the domain schema pure.
|
||||
});
|
||||
|
||||
async function buildDashboardLayout(
|
||||
@@ -193,11 +195,20 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE anything else touches it (issue #581). A malformed token 422s
|
||||
// without a write; an absent token takes the unconditional backward-compat
|
||||
// arm inside `guardedUserUpdate` below.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
|
||||
// on write. The 27 ids (16 web + 11 iOS-only) all validate + persist;
|
||||
// only a genuinely-unknown id (a retired tile, or a typo) is filtered
|
||||
@@ -325,11 +336,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
// v1.32.16 (issue #581) — the concurrency token is a request-only field,
|
||||
// not part of the layout; split it out before the serialize pipeline so
|
||||
// it can never leak into the stored blob.
|
||||
const { baseUpdatedAt, ...layoutData } = parsed.data;
|
||||
let toSerialize = layoutData as Partial<DashboardLayout>;
|
||||
// v1.32.16 (issue #581) — the concurrency token was already split off the
|
||||
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
|
||||
// never leak the token into the stored blob.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
|
||||
const needsRangePointsPreserve =
|
||||
incomingChartOverlayPrefs !== undefined &&
|
||||
@@ -377,49 +387,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
// v1.32.16 (issue #581) — optimistic concurrency. When the client sends
|
||||
// the `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on
|
||||
// the stored row still carrying that token: `updateMany` matches zero
|
||||
// rows once any other request has advanced `updatedAt`, so a Save that
|
||||
// already committed can never be silently reverted by a later request
|
||||
// (e.g. an instant hero-ring PUT) that started from the pre-Save
|
||||
// snapshot. A stale base returns 409 and writes NOTHING. Clients that
|
||||
// omit the token (older web builds, the native client) keep the prior
|
||||
// unconditional write — backward-compatible, still preserve-when-absent.
|
||||
let freshUpdatedAt: Date;
|
||||
if (baseUpdatedAt !== undefined) {
|
||||
const base = new Date(baseUpdatedAt);
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return apiError("Invalid base token", 422, {
|
||||
errorCode: "invalid_base_updated_at",
|
||||
});
|
||||
}
|
||||
const guarded = await prisma.user.updateMany({
|
||||
where: { id: user.id, updatedAt: base },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
});
|
||||
if (guarded.count === 0) {
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.conflict" },
|
||||
meta: { base_updated_at: baseUpdatedAt },
|
||||
});
|
||||
return apiError("Dashboard layout changed since it was loaded", 409, {
|
||||
errorCode: "dashboard_layout_conflict",
|
||||
});
|
||||
}
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
freshUpdatedAt = fresh?.updatedAt ?? new Date();
|
||||
} else {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
freshUpdatedAt = updated?.updatedAt ?? new Date();
|
||||
}
|
||||
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
|
||||
// the shared `guardedUserUpdate` helper. When the client sends the
|
||||
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
|
||||
// stored row still carrying that token, so a Save that already committed
|
||||
// can never be silently reverted by a later request (e.g. an instant
|
||||
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
|
||||
// returns 409 and writes NOTHING. Clients that omit the token (older web
|
||||
// builds, the native client) keep the prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "dashboard.widgets.conflict",
|
||||
errorCode: "dashboard_layout_conflict",
|
||||
message: "Dashboard layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
const freshUpdatedAt = guarded.updatedAt;
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
@@ -433,7 +420,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// Echo the advanced token so the client can base its next write on it.
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: freshUpdatedAt.toISOString(),
|
||||
updatedAt: freshUpdatedAt,
|
||||
} satisfies DashboardLayoutWithToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,13 +23,18 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
} from "@/lib/insights-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -95,12 +100,21 @@ const layoutSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
|
||||
async function buildInsightsLayout(
|
||||
userId: string,
|
||||
): Promise<InsightsLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { insightsLayoutJson: true },
|
||||
// v1.32.21 (R5a) — `updatedAt` rides the cached layout so GET hands the
|
||||
// client the optimistic-concurrency token alongside the layout it applies
|
||||
// to. Cached together (and busted on every write) so the (layout, token)
|
||||
// pair a client reads is always consistent — the widgets precedent.
|
||||
select: { insightsLayoutJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveInsightsLayout(row?.insightsLayoutJson);
|
||||
return {
|
||||
...resolveInsightsLayout(row?.insightsLayoutJson),
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -110,7 +124,7 @@ export const GET = apiHandler(async () => {
|
||||
// changes only when the user hits the Settings save button, which
|
||||
// invalidates this cache via `invalidateUserInsightsLayout()`.
|
||||
const layout = await cached(
|
||||
caches.insightsLayout as ServerCache<InsightsLayout>,
|
||||
caches.insightsLayout as ServerCache<InsightsLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildInsightsLayout(user.id),
|
||||
annotate,
|
||||
@@ -121,11 +135,18 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets route so
|
||||
@@ -179,12 +200,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const previous = await buildInsightsLayout(user.id);
|
||||
const normalized = serializeInsightsLayout(parsed.data, previous);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
insightsLayoutJson: toJson(normalized),
|
||||
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
|
||||
// 409s and writes NOTHING (so an interleaved edit-mode Save can't be
|
||||
// reverted by a Settings pill-order Save that started from an older read,
|
||||
// and vice versa); a tokenless request keeps the prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { insightsLayoutJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "insights.layout.conflict",
|
||||
errorCode: "insights_layout_conflict",
|
||||
message: "Insights layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.layout.update" },
|
||||
@@ -193,7 +223,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: guarded.updatedAt,
|
||||
} satisfies InsightsLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveMedicationListLayout,
|
||||
serializeMedicationListLayout,
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
MEDICATION_LIST_VIEWS,
|
||||
MEDICATION_ORDER_ID_MAX_LENGTH,
|
||||
MEDICATION_ORDER_MAX_ENTRIES,
|
||||
type MedicationListLayout,
|
||||
type MedicationListLayoutWithToken,
|
||||
} from "@/lib/medication-list-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -55,12 +60,18 @@ const layoutSchema = z.object({
|
||||
|
||||
async function buildMedicationListLayout(
|
||||
userId: string,
|
||||
): Promise<MedicationListLayout> {
|
||||
): Promise<MedicationListLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { medicationListLayoutJson: true },
|
||||
// v1.32.21 (R5a) — `updatedAt` rides the cached blob so GET hands the
|
||||
// client the optimistic-concurrency token alongside the presentation it
|
||||
// applies to (busted on every write, so the pair stays consistent).
|
||||
select: { medicationListLayoutJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveMedicationListLayout(row?.medicationListLayoutJson);
|
||||
return {
|
||||
...resolveMedicationListLayout(row?.medicationListLayoutJson),
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -70,7 +81,7 @@ export const GET = apiHandler(async () => {
|
||||
// caches; the blob changes only on a view toggle or an order save,
|
||||
// which invalidates via `invalidateUserMedicationListLayout()`.
|
||||
const layout = await cached(
|
||||
caches.medicationListLayout as ServerCache<MedicationListLayout>,
|
||||
caches.medicationListLayout as ServerCache<MedicationListLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildMedicationListLayout(user.id),
|
||||
annotate,
|
||||
@@ -81,11 +92,20 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
|
||||
// BOTH writers (view toggle, order save) route through here, so the token
|
||||
// guards either against a stale interleave of the other.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets / insights
|
||||
@@ -144,10 +164,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
order: mergedOrder,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
|
||||
// 409s and writes NOTHING, so a view toggle can't resurrect an order the
|
||||
// reorder Save just committed (or vice versa); a tokenless request keeps the
|
||||
// prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { medicationListLayoutJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "medication.layout.conflict",
|
||||
errorCode: "medication_layout_conflict",
|
||||
message: "Medications layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.layout.update" },
|
||||
@@ -156,7 +187,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserMedicationListLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: guarded.updatedAt,
|
||||
} satisfies MedicationListLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -30,10 +30,16 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import {
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
type InsightsSectionConfig,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
|
||||
/**
|
||||
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
|
||||
@@ -119,15 +125,21 @@ export function InsightsEditMode({
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: InsightsLayout) => {
|
||||
return apiPut<InsightsLayout>("/api/insights/layout", {
|
||||
version: 2,
|
||||
sections: next.sections,
|
||||
tiles: next.tiles,
|
||||
});
|
||||
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
|
||||
// was based on so an interleaved write (a Settings pill-order Save, or
|
||||
// this surface open in another tab) 409s instead of clobbering.
|
||||
return apiPut<InsightsLayoutWithToken>(
|
||||
"/api/insights/layout",
|
||||
withBaseToken(
|
||||
{ version: 2, sections: next.sections, tiles: next.tiles },
|
||||
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
// Optimistic-style settle: write the server-resolved layout into the
|
||||
// shared cache so the overview repaints in the new order, then close.
|
||||
// Optimistic-style settle: write the server-resolved layout (carrying
|
||||
// the advanced token) into the shared cache so the overview repaints in
|
||||
// the new order, then close.
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
@@ -135,7 +147,20 @@ export function InsightsEditMode({
|
||||
toast.success(t("insights.editMode.saveSuccess"));
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
onError: (err) => {
|
||||
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means someone else
|
||||
// committed since this edit was based. Refetch so the token advances,
|
||||
// KEEP the draft (stay in edit mode) so the user can re-save, and nudge
|
||||
// gently — nothing was lost.
|
||||
if (isConflict(err)) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("insights.editMode.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
|
||||
|
||||
const FALLBACK_MAX_CHARS = 4000;
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
@@ -114,8 +115,14 @@ export function AboutMeSection({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
// Send only the two fields this panel owns. `conditions` / `coachFocus`
|
||||
// are omitted so the server preserves them (they are edited under Anamnese).
|
||||
// v1.32.21 (R5a) — echo the base token this edit was based on so a
|
||||
// concurrent write to the same self-context (another tab, or Coach adopt)
|
||||
// 409s instead of silently overwriting the newer state.
|
||||
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
|
||||
return apiPut<AboutMeData>("/api/coach/about-me", input);
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
const cleared = !next.aboutMe && !next.allergies;
|
||||
@@ -130,7 +137,15 @@ export function AboutMeSection({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
onError: (err) => {
|
||||
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means the stored
|
||||
// self-context advanced since this edit was based. Refetch so the token
|
||||
// advances, KEEP the drafts so the user can re-save, nudge gently.
|
||||
if (isConflict(err)) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("settings.ai.aboutMe.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
rebuildTilesWithReorderedVitals,
|
||||
} from "@/lib/insights-layout-reorder";
|
||||
import {
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
type InsightsTileConfig,
|
||||
} from "@/lib/insights-layout";
|
||||
import {
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -154,11 +159,15 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
s.id === "ecg" ? { ...s, visible: ecgVisibleDraft } : s,
|
||||
)
|
||||
: layout.sections;
|
||||
return apiPut<InsightsLayout>("/api/insights/layout", {
|
||||
version: 2,
|
||||
sections,
|
||||
tiles,
|
||||
});
|
||||
// v1.32.21 (R5a) — echo the base token so a concurrent insights write
|
||||
// (the overview edit mode, or another tab) 409s instead of clobbering.
|
||||
return apiPut<InsightsLayoutWithToken>(
|
||||
"/api/insights/layout",
|
||||
withBaseToken(
|
||||
{ version: 2, sections, tiles },
|
||||
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
@@ -167,7 +176,18 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
});
|
||||
toast.success(t("insights.pillOrder.saveSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("insights.pillOrder.saveError")),
|
||||
onError: (err) => {
|
||||
// v1.32.21 (R5a) — explicit-Save disposition: on a 409 refetch so the
|
||||
// token advances, KEEP the draft so the user can re-save, nudge gently.
|
||||
if (isConflict(err)) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("insights.pillOrder.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const busy = saveMutation.isPending;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
resolveInsightsLayout,
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
} from "@/lib/insights-layout";
|
||||
|
||||
/**
|
||||
@@ -32,16 +32,30 @@ import {
|
||||
* mode while the layout is still in-flight would seed the editor from
|
||||
* `DEFAULT_INSIGHTS_LAYOUT` and a "Fertig" save would PUT defaults over their
|
||||
* real saved layout.
|
||||
*
|
||||
* v1.32.21 (R5a, B-3) — the resolver rebuilds a FIXED `InsightsLayout` shape
|
||||
* and would DROP the server's `updatedAt` optimistic-concurrency token. If it
|
||||
* did, every insights-layout write would fall back to the server's compat
|
||||
* (unconditional) arm and the #581-class guard would be dead on arrival. So we
|
||||
* re-attach the token AFTER normalising: the query cache holds an
|
||||
* `InsightsLayoutWithToken`, and the two writers (`insights-edit-mode`,
|
||||
* `insights-pill-order-section`) echo it via `readUpdatedAtToken`. Mirrors the
|
||||
* dashboard B-3 fix that kept the token available through its own seed path.
|
||||
*/
|
||||
export function useInsightsLayoutQuery(enabled: boolean): {
|
||||
layout: InsightsLayout;
|
||||
layout: InsightsLayoutWithToken;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
} {
|
||||
const { data, isLoading, isSuccess } = useQuery({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
queryFn: async () =>
|
||||
resolveInsightsLayout(await apiGet("/api/insights/layout")),
|
||||
queryFn: async () => {
|
||||
const raw = await apiGet<InsightsLayoutWithToken>("/api/insights/layout");
|
||||
return {
|
||||
...resolveInsightsLayout(raw),
|
||||
updatedAt: raw?.updatedAt,
|
||||
} satisfies InsightsLayoutWithToken;
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side companion to `src/lib/optimistic-lock.ts` (server).
|
||||
*
|
||||
* The optimistic-concurrency recipe every guarded write follows, extracted
|
||||
* from the shipped `/api/dashboard/widgets` client (issue #581):
|
||||
*
|
||||
* 1. Read the freshest token straight from the query cache AT MUTATE TIME
|
||||
* (`readUpdatedAtToken`) — not a render snapshot — so it reflects
|
||||
* whatever the last settled write or refetch advanced it to.
|
||||
* 2. Echo it on the write body as `baseUpdatedAt` (`withBaseToken`); omit
|
||||
* the field entirely when no token is known yet, which takes the
|
||||
* server's backward-compatible unconditional write.
|
||||
* 3. On a 409 (`isConflict`), invalidate the read so the token advances,
|
||||
* then either keep the draft (explicit-Save surfaces) or roll back the
|
||||
* optimistic delta (instant-write surfaces) and re-apply against fresh
|
||||
* state.
|
||||
*
|
||||
* No `useMutation` wrapper: every writer has bespoke optimistic / draft
|
||||
* logic and a wrapper would obscure it. These are the three shared
|
||||
* primitives the two dispositions are built from.
|
||||
*
|
||||
* The token is opaque — callers only ever echo a server-returned value.
|
||||
*/
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "./api-fetch";
|
||||
|
||||
/**
|
||||
* The current optimistic-concurrency token for a read, straight from the
|
||||
* query cache. Generalises the shipped `readBaseToken`: every guarded GET /
|
||||
* write response carries an optional `updatedAt`, cached with its payload.
|
||||
*/
|
||||
export function readUpdatedAtToken(
|
||||
queryClient: QueryClient,
|
||||
queryKey: QueryKey,
|
||||
): string | undefined {
|
||||
return queryClient.getQueryData<{ updatedAt?: string }>(queryKey)?.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the base token to a write payload — but only when it is known, so a
|
||||
* tokenless client transparently takes the server's unconditional write
|
||||
* (mirrors `buildRingMutationPayload`'s conditional spread).
|
||||
*/
|
||||
export function withBaseToken<T extends object>(
|
||||
payload: T,
|
||||
token: string | undefined,
|
||||
): T & { baseUpdatedAt?: string } {
|
||||
return token !== undefined
|
||||
? { ...payload, baseUpdatedAt: token }
|
||||
: { ...payload };
|
||||
}
|
||||
|
||||
/** True when a thrown error is an optimistic-concurrency 409. */
|
||||
export function isConflict(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 409;
|
||||
}
|
||||
@@ -143,6 +143,17 @@ export interface InsightsLayout {
|
||||
tiles: InsightsTileConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.21 (R5a) — the resolved layout plus the server-stamped `updatedAt`
|
||||
* optimistic-concurrency token (mirrors `User.updatedAt`, never persisted
|
||||
* inside the blob). GET and every write response carry it; the client echoes
|
||||
* it back as `baseUpdatedAt` on the next write. Mirrors
|
||||
* `DashboardLayoutWithToken`.
|
||||
*/
|
||||
export type InsightsLayoutWithToken = InsightsLayout & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
const INSIGHTS_LAYOUT_VERSION = 2;
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,17 @@ export interface MedicationListLayout {
|
||||
* list itself has no hard cap, but the blob must stay small; beyond
|
||||
* this many entries the tail falls back to the alphabetical default.
|
||||
*/
|
||||
/**
|
||||
* v1.32.21 (R5a) — the resolved presentation plus the server-stamped
|
||||
* `updatedAt` optimistic-concurrency token (mirrors `User.updatedAt`, never
|
||||
* persisted inside the blob). GET and every write response carry it; the
|
||||
* client echoes it back as `baseUpdatedAt` on the next write. Mirrors
|
||||
* `DashboardLayoutWithToken` / `InsightsLayoutWithToken`.
|
||||
*/
|
||||
export type MedicationListLayoutWithToken = MedicationListLayout & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const MEDICATION_ORDER_MAX_ENTRIES = 200;
|
||||
|
||||
/** Per-entry id length bound (cuids are 25 chars; leave headroom). */
|
||||
|
||||
@@ -33,7 +33,13 @@ import {
|
||||
coachReminderPatchSchema,
|
||||
coachSuggestedActionSchema,
|
||||
} from "@/lib/validations/coach-reminder";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
} from "./shared";
|
||||
|
||||
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
|
||||
// The action endpoint behind the one-tap reminder-suggestion card. The
|
||||
@@ -191,6 +197,31 @@ const insightsLayoutSchema = z
|
||||
"Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.",
|
||||
});
|
||||
|
||||
// v1.32.21 (R5a) — the PUT body additionally carries the optional
|
||||
// optimistic-concurrency base token (stripped pre-Zod at runtime by
|
||||
// `takeBaseToken`); GET / PUT responses echo the fresh `updatedAt` token.
|
||||
const insightsLayoutPutBody = insightsLayoutSchema
|
||||
.extend({ baseUpdatedAt: baseUpdatedAtField })
|
||||
.meta({
|
||||
id: "InsightsLayoutPutBody",
|
||||
description:
|
||||
"PUT body for the Insights layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
const insightsLayoutResult = insightsLayoutSchema
|
||||
.extend({
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsLayoutResult",
|
||||
description:
|
||||
"Resolved Insights layout plus the optimistic-concurrency `updatedAt` token.",
|
||||
});
|
||||
|
||||
// v1.7.0 — health-record export selection. Strict shape: unknown keys
|
||||
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
|
||||
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
|
||||
@@ -920,13 +951,13 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the calling user's Insights tile layout",
|
||||
description:
|
||||
"Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
|
||||
"Returns the per-user Insights tile layout (visibility + order) plus the optimistic-concurrency `updatedAt` token. Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved layout (custom or default).",
|
||||
description: "The resolved layout (custom or default) plus its token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"),
|
||||
schema: dataEnvelope(insightsLayoutResult, "InsightsLayout"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -937,22 +968,24 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Replace the calling user's Insights tile layout",
|
||||
description:
|
||||
"Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
|
||||
"Persists the full tile layout. The normalised layout plus the advanced `updatedAt` token is returned. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored row changed since; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: insightsLayoutSchema },
|
||||
"application/json": { schema: insightsLayoutPutBody },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout saved; the normalised layout is echoed back.",
|
||||
description:
|
||||
"Layout saved; the normalised layout plus the advanced token is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"),
|
||||
schema: dataEnvelope(insightsLayoutResult, "InsightsLayoutSaved"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409("Insights layout", "insights_layout_conflict"),
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||||
...stdResponses,
|
||||
},
|
||||
@@ -967,7 +1000,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "Layout reset; the default layout is returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"),
|
||||
schema: dataEnvelope(insightsLayoutResult, "InsightsLayoutReset"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1138,15 +1171,21 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Write (or clear) the caller's self-context",
|
||||
description:
|
||||
"v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user.",
|
||||
"v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the `updatedAt` from a prior read) and the write 409s if the stored self-context changed since; omit it for the legacy unconditional write. The token is per-surface (`UserHealthProfile.updatedAt`), so it is not perturbed by unrelated account writes.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: aboutMePutSchema } },
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: aboutMePutSchema.extend({
|
||||
baseUpdatedAt: baseUpdatedAtField,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The effective (trimmed) state echoed back plus the freshly derived pending questions.",
|
||||
"The effective (trimmed) state echoed back plus the freshly derived pending questions and the advanced `updatedAt` token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
@@ -1165,6 +1204,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409("Self-context", "about_me_conflict"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* OpenAPI route table — dashboard widget layout (`/api/dashboard/widgets`).
|
||||
*
|
||||
* v1.32.21 (R5a) — this endpoint was ENTIRELY absent from the registry
|
||||
* even though it is the reference implementation for the optimistic-
|
||||
* concurrency token contract the native client is being asked to adopt
|
||||
* (issue #581, shipped v1.32.16). Documenting it here closes that gap: the
|
||||
* contract iOS validates against now carries the `baseUpdatedAt` request
|
||||
* field, the `updatedAt` response token, and the 409 conflict envelope.
|
||||
*
|
||||
* The schema mirrors the inline Zod shape in
|
||||
* `src/app/api/dashboard/widgets/route.ts`; the id sets are imported from
|
||||
* the single-source `@/lib/dashboard-layout` module so the contract cannot
|
||||
* drift. Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
DASHBOARD_WIDGET_CATALOGUE_IDS,
|
||||
COMPARISON_BASELINES,
|
||||
CHART_OVERLAY_KEYS,
|
||||
SCORE_RING_IDS,
|
||||
HERO_RING_IDS,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import {
|
||||
dataEnvelope,
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
} from "./shared";
|
||||
|
||||
const dashboardWidgetConfig = z.object({
|
||||
id: z.enum(DASHBOARD_WIDGET_CATALOGUE_IDS),
|
||||
visible: z.boolean(),
|
||||
tileVisible: z.boolean().optional(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
});
|
||||
|
||||
const dashboardChartOverlayPrefs = z.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: z.boolean(),
|
||||
showTargetRange: z.boolean(),
|
||||
comparisonBaseline: z.enum(COMPARISON_BASELINES).optional(),
|
||||
rangePoints: z
|
||||
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// The layout shape shared by GET/PUT/DELETE. `widgets` (and the other
|
||||
// preserve-when-absent fields) are optional on the wire: the Settings
|
||||
// instant score-ring PUT omits `widgets` entirely so the server carries
|
||||
// forward whatever is currently stored (issue #581).
|
||||
const dashboardLayoutSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
widgets: z.array(dashboardWidgetConfig).min(1).max(50).optional(),
|
||||
comparisonBaseline: z.enum(COMPARISON_BASELINES).optional(),
|
||||
chartOverlayPrefs: z
|
||||
.partialRecord(z.enum(CHART_OVERLAY_KEYS), dashboardChartOverlayPrefs)
|
||||
.optional(),
|
||||
selectedScoreRings: z.array(z.enum(SCORE_RING_IDS)).max(3).optional(),
|
||||
heroRingOrder: z.array(z.enum(HERO_RING_IDS)).max(4).optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "DashboardLayoutBody",
|
||||
description:
|
||||
"Per-user dashboard widget layout. `widgets` is the ordered tile/chart list (each with a visibility flag plus the independent `tileVisible` strip-tile flag); `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, and `heroRingOrder` are the additive dashboard-preference fields. Every field except `version` is optional on input and preserve-when-absent: an omitted field is carried forward from the stored layout rather than reset, so a single-surface PUT (e.g. the instant hero-ring toggle, which omits `widgets`) can never clobber a choice made on another surface. Widget ids are the closed 27-id catalogue (16 web + 11 iOS-only); unknown ids are dropped before validation.",
|
||||
});
|
||||
|
||||
const dashboardLayoutPutBody = dashboardLayoutSchema
|
||||
.extend({ baseUpdatedAt: baseUpdatedAtField })
|
||||
.meta({
|
||||
id: "DashboardLayoutPutBody",
|
||||
description:
|
||||
"PUT body for the dashboard layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
|
||||
const dashboardLayoutResult = dashboardLayoutSchema
|
||||
.extend({
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "DashboardLayoutResult",
|
||||
description:
|
||||
"Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token.",
|
||||
});
|
||||
|
||||
export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/widgets": {
|
||||
get: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Read the calling user's dashboard widget layout",
|
||||
description:
|
||||
"Returns the resolved effective layout (defaults merged in if the user has not customised it) plus the optimistic-concurrency `updatedAt` token.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved layout (custom or default) plus its token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(dashboardLayoutResult, "DashboardLayout"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Replace the calling user's dashboard widget layout",
|
||||
description:
|
||||
"Persists the layout with preserve-when-absent semantics and returns the normalised layout plus the advanced `updatedAt` token. Optimistic concurrency (v1.32.16): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored row changed since — so a committed Save can never be silently reverted by a later request that started from an older snapshot; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: dashboardLayoutPutBody },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Layout saved; the normalised layout plus the advanced token is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dashboardLayoutResult,
|
||||
"DashboardLayoutSaved",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409("Dashboard layout", "dashboard_layout_conflict"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Reset the calling user's dashboard widget layout",
|
||||
description:
|
||||
"Clears the persisted layout and returns the default layout. Idempotent.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout reset; the default layout is returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dashboardLayoutResult,
|
||||
"DashboardLayoutReset",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import { consentPaths } from "./consent";
|
||||
import { customMetricPaths } from "./custom-metrics";
|
||||
import { dailyPaths } from "./daily";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { dashboardWidgetPaths } from "./dashboard";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { inboundDocumentPaths } from "./documents";
|
||||
import { devicePaths } from "./devices";
|
||||
@@ -105,6 +106,10 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
// v1.28 — the unified daily digest (P3 spine; appended, spread order is
|
||||
// load-bearing).
|
||||
...dailyPaths,
|
||||
// v1.32.21 (R5a) — dashboard widget layout: previously absent from the
|
||||
// registry entirely, now documented as the reference optimistic-concurrency
|
||||
// contract (appended, spread order is load-bearing).
|
||||
...dashboardWidgetPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
efficacyTargetOverrideSchema,
|
||||
medicationEfficacyResponseSchema,
|
||||
} from "@/lib/validations/medication-efficacy";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "../shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
stdResponses,
|
||||
conflictResponse409,
|
||||
} from "../shared";
|
||||
|
||||
efficacyTargetOverrideSchema.meta({
|
||||
id: "SetMedicationEfficacyTargetRequest",
|
||||
@@ -47,7 +52,8 @@ import {
|
||||
scheduleRevisionResource,
|
||||
scheduleRevisionListResponse,
|
||||
medicationExtractRequest,
|
||||
medicationListLayoutSchema,
|
||||
medicationListLayoutPutBody,
|
||||
medicationListLayoutResult,
|
||||
doseHistoryQuery,
|
||||
doseHistoryResponse,
|
||||
} from "./schemas";
|
||||
@@ -224,14 +230,14 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Read the calling user's medications list presentation",
|
||||
description:
|
||||
"Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.",
|
||||
"Returns the per-user /medications presentation (card/table view + manual order) plus the optimistic-concurrency `updatedAt` token. Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved presentation (custom or default).",
|
||||
description: "The resolved presentation (custom or default) plus its token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
medicationListLayoutResult,
|
||||
"MedicationListLayoutResponse",
|
||||
),
|
||||
},
|
||||
@@ -244,26 +250,30 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Update the calling user's medications list presentation",
|
||||
description:
|
||||
"Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.",
|
||||
"Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation plus the advanced `updatedAt` token is returned. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored blob changed since; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: medicationListLayoutSchema },
|
||||
"application/json": { schema: medicationListLayoutPutBody },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Presentation saved; the normalised blob is echoed back.",
|
||||
"Presentation saved; the normalised blob plus the advanced token is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
medicationListLayoutResult,
|
||||
"MedicationListLayoutSaved",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409(
|
||||
"Medications layout",
|
||||
"medication_layout_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -278,7 +288,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
medicationListLayoutResult,
|
||||
"MedicationListLayoutReset",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { baseUpdatedAtField } from "../shared";
|
||||
import {
|
||||
MEDICATION_CATEGORY_VALUES,
|
||||
MEDICATION_CONTAINER_TYPE_VALUES,
|
||||
@@ -894,3 +895,29 @@ export const medicationListLayoutSchema = z
|
||||
description:
|
||||
"Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.",
|
||||
});
|
||||
|
||||
// v1.32.21 (R5a) — the PUT body additionally carries the optional
|
||||
// optimistic-concurrency base token (stripped pre-Zod at runtime by
|
||||
// `takeBaseToken`); GET / PUT responses echo the fresh `updatedAt` token.
|
||||
export const medicationListLayoutPutBody = medicationListLayoutSchema
|
||||
.extend({ baseUpdatedAt: baseUpdatedAtField })
|
||||
.meta({
|
||||
id: "MedicationListLayoutPutBody",
|
||||
description:
|
||||
"PUT body for the medications list presentation — the presentation fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
|
||||
export const medicationListLayoutResult = medicationListLayoutSchema
|
||||
.extend({
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationListLayoutResult",
|
||||
description:
|
||||
"Resolved medications list presentation plus the optimistic-concurrency `updatedAt` token.",
|
||||
});
|
||||
@@ -91,6 +91,41 @@ createBatchWorkoutSchema.meta({
|
||||
"Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
|
||||
});
|
||||
|
||||
// ── Optimistic concurrency (v1.32.21 / R5a) ──────────────────────────
|
||||
// The write endpoints that read-modify-write a per-user blob accept an
|
||||
// optional `baseUpdatedAt` base token (the `updatedAt` the client last
|
||||
// read) and guard the write on the stored row still carrying it. A stale
|
||||
// token fails the write with 409 and changes nothing; an omitted token
|
||||
// takes the prior unconditional write (backward-compatible). The token is
|
||||
// OPAQUE — clients only ever echo a server-returned value.
|
||||
|
||||
/**
|
||||
* Optional request field carrying the optimistic-concurrency base token.
|
||||
* Extend a request schema with `{ baseUpdatedAt: baseUpdatedAtField }` at the
|
||||
* OpenAPI layer: the runtime strips it pre-Zod (`takeBaseToken`), so the
|
||||
* runtime schema alone would under-document the wire.
|
||||
*/
|
||||
export const baseUpdatedAtField = z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
|
||||
);
|
||||
|
||||
/**
|
||||
* The 409 the guarded write returns when the base token is stale. `errorCode`
|
||||
* is per-endpoint; the caller passes the resource noun + the concrete
|
||||
* errorCode so the prose enumerates it.
|
||||
*/
|
||||
export function conflictResponse409(resource: string, errorCode: string) {
|
||||
return {
|
||||
"409": {
|
||||
description: `${resource} changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. \`meta.errorCode\` = \`${errorCode}\`.`,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
export const stdResponses = {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Optimistic-concurrency guard for per-user read-modify-write endpoints.
|
||||
*
|
||||
* Extracted from the shipped `/api/dashboard/widgets` fix (issue #581,
|
||||
* v1.32.16): a Save carrying the `updatedAt` it was based on writes
|
||||
* CONDITIONALLY on the stored row still carrying that token, so a Save
|
||||
* that already committed can never be silently reverted by a later
|
||||
* request that started from an older snapshot. A stale base returns 409
|
||||
* and writes NOTHING. A request that omits the token keeps the prior
|
||||
* unconditional write — byte-for-byte the pre-guard behaviour, so older
|
||||
* web builds and the native client are unaffected.
|
||||
*
|
||||
* The wire token is OPAQUE by contract: the client only ever echoes a
|
||||
* server-returned value, never parses or synthesises one. That keeps the
|
||||
* escalation path (a per-column jsonb value guard instead of the shared
|
||||
* `User.updatedAt`) wire-compatible — the token can become a hash of the
|
||||
* column value without any client change.
|
||||
*
|
||||
* Two consumer shapes:
|
||||
* - `guardedUserUpdate` — the common case: a field-by-field
|
||||
* `Prisma.UserUpdateInput` guarded on `User.updatedAt`.
|
||||
* - `guardedRowUpdate` — the generic core `guardedUserUpdate` wraps, for
|
||||
* a non-`User` row that carries its own `@updatedAt` (e.g.
|
||||
* `UserHealthProfile` behind `/api/coach/about-me`).
|
||||
*
|
||||
* The caller always builds the `data` object field-by-field (no mass
|
||||
* assignment); this helper never spreads a parsed request body.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** Result of pulling the `baseUpdatedAt` transport field off a request body. */
|
||||
export type TakeBaseTokenResult =
|
||||
| { base: Date | undefined; rest: unknown }
|
||||
| { invalid: true };
|
||||
|
||||
/**
|
||||
* Pull the optimistic-concurrency base token off a raw request body BEFORE
|
||||
* the domain Zod parse, so the token never has to be declared on (and can
|
||||
* never pollute) the domain schema — several of these schemas are `.strict()`
|
||||
* or derive a change list from `Object.keys(parsed.data)`.
|
||||
*
|
||||
* - no `baseUpdatedAt` key → `{ base: undefined, rest: body }` (legacy write)
|
||||
* - `baseUpdatedAt` a valid ISO date → `{ base: Date, rest: body-without-the-field }`
|
||||
* - `baseUpdatedAt` present but junk → `{ invalid: true }` (caller returns 422)
|
||||
*
|
||||
* A non-object body passes through untouched as a tokenless request; the
|
||||
* domain Zod parse then rejects it on its own terms.
|
||||
*/
|
||||
export function takeBaseToken(body: unknown): TakeBaseTokenResult {
|
||||
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||
return { base: undefined, rest: body };
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
if (!("baseUpdatedAt" in record)) {
|
||||
return { base: undefined, rest: body };
|
||||
}
|
||||
const { baseUpdatedAt, ...rest } = record;
|
||||
if (baseUpdatedAt === undefined) {
|
||||
// Present-but-undefined can't arrive over JSON; treat it as tokenless.
|
||||
return { base: undefined, rest };
|
||||
}
|
||||
if (typeof baseUpdatedAt !== "string") {
|
||||
return { invalid: true };
|
||||
}
|
||||
const base = new Date(baseUpdatedAt);
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return { invalid: true };
|
||||
}
|
||||
return { base, rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* The single 422 a malformed base token earns — same wording + errorCode
|
||||
* across every guarded endpoint (matches the shipped widgets route).
|
||||
*/
|
||||
export function invalidBaseTokenError(): Response {
|
||||
return apiError("Invalid base token", 422, {
|
||||
errorCode: "invalid_base_updated_at",
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-endpoint conflict identity (annotation name + wire errorCode + prose). */
|
||||
export interface ConflictSpec {
|
||||
/** Wide-event action name, `<surface>.<noun>.conflict`. */
|
||||
action: string;
|
||||
/** `meta.errorCode` on the 409 envelope. */
|
||||
errorCode: string;
|
||||
/** Human prose for the 409 `error` string. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Either a ready-to-return 409 `Response`, or the fresh token to echo. */
|
||||
export type GuardedUpdateResult =
|
||||
| { conflict: Response }
|
||||
| { updatedAt: string };
|
||||
|
||||
/**
|
||||
* Generic conditional-update-or-409 core.
|
||||
*
|
||||
* - `base === undefined` → run the UNCONDITIONAL write (backward-compat
|
||||
* arm; this must stay the plain write forever) and echo its token.
|
||||
* - `base` present → run the CONDITIONAL write; a zero match means the
|
||||
* row advanced since the client read it → annotate + return a 409
|
||||
* with NO write; a match re-reads the fresh token to echo.
|
||||
*/
|
||||
export async function guardedRowUpdate(opts: {
|
||||
base: Date | undefined;
|
||||
run: {
|
||||
/** The prior unconditional write; returns the row's fresh `updatedAt`. */
|
||||
unconditional: () => Promise<{ updatedAt: Date }>;
|
||||
/** The conditional write (guarded on `base`); returns the matched count. */
|
||||
conditional: (base: Date) => Promise<number>;
|
||||
/** Re-read the row's `updatedAt` after a matched conditional write. */
|
||||
freshToken: () => Promise<Date | undefined>;
|
||||
};
|
||||
conflict: ConflictSpec;
|
||||
}): Promise<GuardedUpdateResult> {
|
||||
const { base, run, conflict } = opts;
|
||||
|
||||
if (base === undefined) {
|
||||
const updated = await run.unconditional();
|
||||
return { updatedAt: (updated.updatedAt ?? new Date()).toISOString() };
|
||||
}
|
||||
|
||||
const count = await run.conditional(base);
|
||||
if (count === 0) {
|
||||
annotate({
|
||||
action: { name: conflict.action },
|
||||
meta: { base_updated_at: base.toISOString() },
|
||||
});
|
||||
return {
|
||||
conflict: apiError(conflict.message, 409, {
|
||||
errorCode: conflict.errorCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const fresh = await run.freshToken();
|
||||
return { updatedAt: (fresh ?? new Date()).toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* `guardedRowUpdate` specialised to the `User` row: a field-by-field
|
||||
* `Prisma.UserUpdateInput` guarded on `User.updatedAt`. The `data` object is
|
||||
* always built by the caller — this helper never spreads a request body.
|
||||
*/
|
||||
export function guardedUserUpdate(opts: {
|
||||
userId: string;
|
||||
base: Date | undefined;
|
||||
data: Prisma.UserUpdateInput;
|
||||
conflict: ConflictSpec;
|
||||
}): Promise<GuardedUpdateResult> {
|
||||
const { userId, base, data, conflict } = opts;
|
||||
return guardedRowUpdate({
|
||||
base,
|
||||
conflict,
|
||||
run: {
|
||||
unconditional: async () => {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return { updatedAt: updated?.updatedAt ?? new Date() };
|
||||
},
|
||||
conditional: async (b) => {
|
||||
const res = await prisma.user.updateMany({
|
||||
where: { id: userId, updatedAt: b },
|
||||
data,
|
||||
});
|
||||
return res.count;
|
||||
},
|
||||
freshToken: async () => {
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return fresh?.updatedAt;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_MEDICATION_LIST_LAYOUT,
|
||||
type MedicationListLayout,
|
||||
type MedicationListLayoutWithToken,
|
||||
type MedicationListView,
|
||||
} from "@/lib/medication-list-layout";
|
||||
|
||||
@@ -47,19 +52,28 @@ export async function runSetMedicationListView(deps: {
|
||||
}): Promise<void> {
|
||||
const { view, queryClient, t } = deps;
|
||||
const key = queryKeys.medicationListLayout();
|
||||
const previous = queryClient.getQueryData<MedicationListLayout>(key);
|
||||
queryClient.setQueryData<MedicationListLayout>(key, {
|
||||
const previous = queryClient.getQueryData<MedicationListLayoutWithToken>(key);
|
||||
queryClient.setQueryData<MedicationListLayoutWithToken>(key, {
|
||||
...(previous ?? DEFAULT_MEDICATION_LIST_LAYOUT),
|
||||
view,
|
||||
});
|
||||
try {
|
||||
const saved = await apiPut<MedicationListLayout>(
|
||||
// v1.32.21 (R5a) — echo the base token so a concurrent order Save can't be
|
||||
// resurrected by this view toggle (and vice versa).
|
||||
const saved = await apiPut<MedicationListLayoutWithToken>(
|
||||
"/api/medications/layout",
|
||||
{ version: 1, view },
|
||||
withBaseToken({ version: 1, view }, readUpdatedAtToken(queryClient, key)),
|
||||
);
|
||||
if (saved) queryClient.setQueryData(key, saved);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Instant-write disposition: roll back the optimistic view. On a 409 also
|
||||
// invalidate so the token advances, and nudge instead of a hard error.
|
||||
queryClient.setQueryData(key, previous);
|
||||
if (isConflict(err)) {
|
||||
void queryClient.invalidateQueries({ queryKey: key });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("medications.viewSaveFailed"));
|
||||
}
|
||||
}
|
||||
@@ -76,25 +90,35 @@ export async function runSaveMedicationListOrder(deps: {
|
||||
t: Translator;
|
||||
}): Promise<boolean> {
|
||||
const { order, queryClient, t } = deps;
|
||||
const key = queryKeys.medicationListLayout();
|
||||
try {
|
||||
const saved = await apiPut<MedicationListLayout>(
|
||||
// v1.32.21 (R5a) — echo the base token so this order Save 409s rather than
|
||||
// clobbering a view toggle that committed since the dialog opened.
|
||||
const saved = await apiPut<MedicationListLayoutWithToken>(
|
||||
"/api/medications/layout",
|
||||
{ version: 1, order },
|
||||
withBaseToken({ version: 1, order }, readUpdatedAtToken(queryClient, key)),
|
||||
);
|
||||
if (saved) {
|
||||
queryClient.setQueryData(queryKeys.medicationListLayout(), saved);
|
||||
queryClient.setQueryData(key, saved);
|
||||
}
|
||||
toast.success(t("medications.reorderSaved"));
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Explicit-Save disposition: on a 409 refetch so the token advances, keep
|
||||
// the dialog open (return false), nudge gently.
|
||||
if (isConflict(err)) {
|
||||
void queryClient.invalidateQueries({ queryKey: key });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return false;
|
||||
}
|
||||
toast.error(t("medications.reorderSaveFailed"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useMedicationListLayout(enabled: boolean = true): {
|
||||
/** Resolved layout — defaults until the GET lands. */
|
||||
layout: MedicationListLayout;
|
||||
/** Resolved layout (plus the optimistic-concurrency token) — defaults until the GET lands. */
|
||||
layout: MedicationListLayoutWithToken;
|
||||
/** True while the preference GET is in flight (first load only). */
|
||||
isLayoutLoading: boolean;
|
||||
setView: (view: MedicationListView) => Promise<void>;
|
||||
@@ -105,7 +129,8 @@ export function useMedicationListLayout(enabled: boolean = true): {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.medicationListLayout(),
|
||||
queryFn: () => apiGet<MedicationListLayout>("/api/medications/layout"),
|
||||
queryFn: () =>
|
||||
apiGet<MedicationListLayoutWithToken>("/api/medications/layout"),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user