diff --git a/messages/de.json b/messages/de.json index 8d059356a..851fc70fc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 71589ba8c..e99edae8d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index f2c3d9c56..6f0c48098 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 6935e8e9d..f666f89fc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 18e171790..0eadb7161 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 29cc73add..b7541105f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 8459f4f30..ff498c5ec 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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, diff --git a/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts new file mode 100644 index 000000000..1a7c2ac1c --- /dev/null +++ b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts @@ -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): 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); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index c83336dd4..7f5cfcec8 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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"), );