From 706af61cdd865f2811c67a20c32d5cfa77907df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:52:39 +0100 Subject: [PATCH] fix(custom-metrics): say "name taken" only for a name clash The form read every 409 as "a metric with this name already exists". Now that it keeps one idempotency key across taps, a second tap while the first create is still running also answers 409, and the form told the person their name was taken. The create and rename routes send `customMetric.name_taken` with the clash, and the form matches the code. --- src/app/api/custom-metrics/[id]/__tests__/route.test.ts | 1 + src/app/api/custom-metrics/[id]/route.ts | 4 +++- src/app/api/custom-metrics/__tests__/route.test.ts | 3 +++ src/app/api/custom-metrics/route.ts | 4 +++- src/components/custom-metrics/custom-metric-form.tsx | 4 +++- src/lib/openapi/error-codes.ts | 1 + 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 76d08eb28..f9f693eb4 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -225,6 +225,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { const res = await PATCH(patchReq({ name: "Other" }), params("cm-1")); expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 2b149bd4d..a26fdbe35 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -111,7 +111,9 @@ export const PATCH = apiHandler( select: { id: true }, }); if (clash) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } } diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 1a9a0492c..fba6c5514 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -134,6 +134,9 @@ describe("POST /api/custom-metrics", () => { const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); expect(res.status).toBe(409); + // The form names the clash by this code: a 409 without it can be the + // first save still running under the same key. + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.create).not.toHaveBeenCalled(); }); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index dfc9aa12e..88fcc1bca 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -100,7 +100,9 @@ async function postCustomMetric(request: NextRequest) { select: { id: true, deletedAt: true }, }); if (existing && existing.deletedAt === null) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } // Field-by-field assignment — never spread `parsed.data`. diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index e0cb0a053..664fdd911 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -132,8 +132,10 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { + // By its code: a 409 can also be the first save still running. setError( - err instanceof ApiError && err.status === 409 + err instanceof ApiError && + err.meta?.errorCode === "customMetric.name_taken" ? t("customMetrics.form.nameTaken") : localizedApiError(err, t, "customMetrics.form.saveError"), ); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index fa30f1bf2..ac7344c5a 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -140,6 +140,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "coach-prefs.body.invalid_shape", ], consent: ["consent.ai.confirmRequired", "consent.ai.required"], + customMetric: ["customMetric.name_taken"], cycle: [ "cycle.bulk.invalid", "cycle.bulk.too_large",