fix(api): return 409 on duplicate-timestamp measurement edit

The PUT /api/measurements/[id] handler dispatched the Prisma
update without a try/catch, so re-pointing measuredAt onto an
existing (userId, type, measuredAt, source, sleepStage) tuple
bubbled the P2002 violation as a 500 and the row-edit Sheet
rendered the generic save-error toast.

Mirror the POST handler's P2002 catch: return a clean 409 with a
stable `meta.errorCode = "measurement.duplicate_timestamp"` and a
new measurements.duplicateTimestamp i18n key across the six
locales. The mutation in measurement-list.tsx reads the error
code from the response envelope and surfaces the localised
banner instead of the upstream English fallback.

iOS contract — PATCH /api/measurements/{id}: additive. The 200
happy-path response shape (MeasurementWireDTO), the 422
validation path and the updateMeasurementSchema are untouched.
The new 409 path is strictly clearer than the 500 it replaces.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 00:15:47 +02:00
1 parent 948fcd9318
commit 538b44f74d
9 files changed
+213 -10

No files matched your search

+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "z.B. nach dem Essen",
"formReset": "Formular zurücksetzen",
"saveError": "Fehler beim Speichern",
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
"deleteError": "Fehler beim Löschen",
"invalidValue": "Bitte einen gültigen Wert eingeben.",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
+1
View File
@@ -331,6 +331,7 @@
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
+30 -8
View File
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -62,14 +63,35 @@ export const PUT = apiHandler(
}
const data = parsed.data;
const measurement = await prisma.measurement.update({
where: { id },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
let measurement;
try {
measurement = await prisma.measurement.update({
where: { id },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
} catch (err) {
// v1.4.28 FB-B1 — re-pointing `measuredAt` onto an existing
// `(userId, type, measuredAt, source, sleepStage)` tuple raises
// `P2002`. Mirror the POST handler's catch so the row-edit Sheet
// surfaces a clean 409 with a translatable `errorCode` instead of
// the bare 500 the UI used to render as the generic save-error
// toast.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return apiError(
"A measurement with this timestamp already exists",
409,
{ errorCode: "measurement.duplicate_timestamp" },
);
}
throw err;
}
await auditLog("measurement.update", {
userId: user.id,
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { Prisma } from "@/generated/prisma/client";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
return new NextRequest(`http://localhost/api/measurements/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
it("returns 409 when re-pointing measuredAt onto an existing tuple", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "m-2",
userId: "user-1",
type: "ACTIVITY_STEPS",
value: 1234,
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
source: "MANUAL",
sleepStage: null,
notes: null,
unit: "count",
} as never);
// Mirror what Prisma raises on a unique-constraint collision.
const p2002 = new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "x",
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
},
);
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
const res = await PUT(
putRequest("m-2", { measuredAt: "2026-05-10T09:00:00.000Z" }),
{ params: Promise.resolve({ id: "m-2" }) },
);
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
expect(json.error).toMatch(/already exists|duplicate/i);
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
});
it("re-throws any non-P2002 Prisma error", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "m-3",
userId: "user-1",
type: "WEIGHT",
value: 75,
measuredAt: new Date(),
source: "MANUAL",
sleepStage: null,
notes: null,
unit: "kg",
} as never);
const otherError = new Prisma.PrismaClientKnownRequestError(
"Record not found",
{ code: "P2025", clientVersion: "x" },
);
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
const res = await PUT(
putRequest("m-3", { value: 76 }),
{ params: Promise.resolve({ id: "m-3" }) },
);
// apiHandler converts uncaught errors to 500.
expect(res.status).toBe(500);
});
it("returns the updated measurement on the 200 happy path", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "m-4",
userId: "user-1",
type: "WEIGHT",
value: 75,
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
source: "MANUAL",
sleepStage: null,
notes: null,
unit: "kg",
} as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({
id: "m-4",
userId: "user-1",
type: "WEIGHT",
value: 76,
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
source: "MANUAL",
sleepStage: null,
notes: null,
unit: "kg",
} as never);
const res = await PUT(
putRequest("m-4", { value: 76 }),
{ params: Promise.resolve({ id: "m-4" }) },
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { id: string; value: number } };
expect(json.data.id).toBe("m-4");
expect(json.data.value).toBe(76);
});
});
@@ -232,9 +232,24 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
}),
});
const json = await res.json();
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
if (!res.ok) {
throw new Error(json.error ?? "Update failed");
// v1.4.28 FB-B1 — the PUT route now returns a 409 with
// `meta.errorCode === "measurement.duplicate_timestamp"` when
// the edit collides with an existing row's
// `(type, measuredAt, source, sleepStage)` tuple. Forward the
// code alongside the message so `onError` can pick the
// localised string.
const err = new Error(json.error ?? "Update failed") as Error & {
errorCode?: string;
status?: number;
};
err.errorCode = json.meta?.errorCode;
err.status = res.status;
throw err;
}
},
onSuccess: async () => {
@@ -243,6 +258,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
setEditError(null);
},
onError: (err) => {
const errWithCode = err as Error & { errorCode?: string };
if (errWithCode.errorCode === "measurement.duplicate_timestamp") {
setEditError(t("measurements.duplicateTimestamp"));
return;
}
setEditError(
err instanceof Error ? err.message : t("measurements.saveError"),
);