mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user