feat(sites): admins can correct a site's project code

The project code was locked once set, on the belief that changing it
would break joins. Nothing joins on it: every record links to the site by
id, and the PO email intake reads codes live. A mistyped code (Nellmapius
was 001 instead of C26/27) could only be fixed in the database.

In Edit site an admin double-clicks the code to unlock it. PATCH accepts
project_code from admins only, trims it, requires 1-64 characters, answers
409 when another site already uses it, and writes nothing when unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-11-02 22:59:30 +02:00
parent 4a2e39be86
commit 607de20232
4 changed files with 109 additions and 16 deletions

View file

@ -2,23 +2,33 @@ import "@/test-utils/named-admin-session";
import { describe, it, expect, vi, beforeEach } from "vitest";
const pbUpdate = vi.fn();
const pbGetOne = vi.fn();
const pbGetList = vi.fn();
vi.mock("@/lib/pb", () => ({
pbAdmin: async () => ({
filter: (expression: string, params: Record<string, string>) => expression.replace(/\{:(\w+)\}/g, (_, key: string) => JSON.stringify(params[key])),
collection: (name: string) => {
if (name === "sites") return { update: pbUpdate };
if (name === "sites") return { update: pbUpdate, getOne: pbGetOne, getList: pbGetList };
throw new Error(`unexpected collection: ${name}`);
},
}),
}));
import { PATCH } from "./route";
import { getCurrentUser } from "@/lib/auth/session";
import { namedAdmin } from "@/test-utils/named-admin-session";
const SITE_ID = "site_abc";
beforeEach(() => {
pbUpdate.mockReset();
pbUpdate.mockResolvedValue({ id: SITE_ID });
pbGetOne.mockReset();
pbGetOne.mockResolvedValue({ id: SITE_ID, project_code: "001" });
pbGetList.mockReset();
pbGetList.mockResolvedValue({ totalItems: 0, items: [] });
vi.mocked(getCurrentUser).mockResolvedValue(namedAdmin);
});
const patch = (body: unknown) =>
@ -88,3 +98,37 @@ describe("PATCH /api/sites/[id] — coordinates", () => {
expect(payload).not.toHaveProperty("longitude");
});
});
describe("PATCH /api/sites/[id] — project code", () => {
it("lets an admin correct the project code, trimmed", async () => {
const res = await patch({ project_code: " C26/27 " });
expect(res.status).toBe(200);
expect(pbUpdate).toHaveBeenCalledWith(SITE_ID, expect.objectContaining({ project_code: "C26/27" }));
});
it("refuses a project code another site already uses", async () => {
pbGetList.mockResolvedValue({ totalItems: 1, items: [{ id: "other" }] });
const res = await patch({ project_code: "C26/09" });
expect(res.status).toBe(409);
expect(pbUpdate).not.toHaveBeenCalled();
});
it("refuses an empty or over-long project code", async () => {
expect((await patch({ project_code: " " })).status).toBe(400);
expect((await patch({ project_code: "x".repeat(65) })).status).toBe(400);
expect(pbUpdate).not.toHaveBeenCalled();
});
it("does not let a site manager change it", async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ ...namedAdmin, role: "site_user", siteIds: [SITE_ID] });
const res = await patch({ project_code: "C26/27" });
expect(res.status).toBe(403);
expect(pbUpdate).not.toHaveBeenCalled();
});
it("writes nothing extra when the code is unchanged", async () => {
const res = await patch({ project_code: "001", name: "Nellmapius Ext 25" });
expect(res.status).toBe(200);
expect(pbUpdate.mock.calls[0][1]).not.toHaveProperty("project_code");
});
});

View file

@ -2,13 +2,15 @@ import { authorizeOperation } from "@/lib/auth/operation";
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { parseCoordinate, type CoordinateKind } from "@/lib/site-coordinates";
import { requireApiUser } from "@/lib/auth/guard";
/**
* PATCH /api/sites/[id]
*
* Partial update — only fields present in the body are written.
* project_code is locked (changing it would break joins); use null on
* job_start_date to clear it explicitly.
* project_code can be corrected by an admin only. Every other record links
* to the site by id, so a corrected code breaks nothing; it must stay unique.
* Use null on job_start_date to clear it explicitly.
*/
export async function PATCH(
request: Request,
@ -24,14 +26,35 @@ export async function PATCH(
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
const payload: Record<string, unknown> = {};
const pb = await pbAdmin();
if ("project_code" in body) {
return NextResponse.json(
{ error: "project_code cannot be changed once a site exists" },
{ status: 400 },
);
const projectCode = typeof body.project_code === "string" ? body.project_code.trim() : "";
if (!projectCode) return NextResponse.json({ error: "project_code is required" }, { status: 400 });
if (projectCode.length > 64) {
return NextResponse.json({ error: "project_code must be 64 characters or fewer" }, { status: 400 });
}
let current: { project_code?: string };
try {
current = await pb.collection("sites").getOne(id, { fields: "project_code" });
} catch {
return NextResponse.json({ error: "Site not found" }, { status: 404 });
}
if (projectCode !== current.project_code) {
const admin = await requireApiUser("dashboard", { admin: true });
if (admin instanceof Response) return NextResponse.json({ error: "Only an admin can change a project code" }, { status: 403 });
const clash = await pb.collection("sites").getList(1, 1, {
filter: pb.filter("project_code = {:code} && id != {:id}", { code: projectCode, id }),
fields: "id",
});
if (clash.totalItems > 0) {
return NextResponse.json({ error: `project_code "${projectCode}" is already in use` }, { status: 409 });
}
payload.project_code = projectCode;
}
}
const payload: Record<string, unknown> = {};
for (const k of ["name", "duration"] as const) {
if (typeof body[k] === "string") payload[k] = body[k];
}
@ -66,7 +89,6 @@ export async function PATCH(
}
}
const pb = await pbAdmin();
try {
const updated = await pb.collection("sites").update(id, payload);
return NextResponse.json({ site: updated });

View file

@ -28,6 +28,7 @@ import {
import { Topbar } from "@/components/layout/sidebar";
import { Button } from "@/components/ui/button";
import { EditSiteButton } from "@/components/site-form-dialog";
import { getCurrentUser } from "@/lib/auth/session";
import { type SearchParams } from "@/lib/site-scope";
import { getSiteScope } from "@/lib/site-scope-server";
import {
@ -63,6 +64,7 @@ export default async function SiteDetailPage({
// to revisit five files.
const scope = await getSiteScope(await searchParams, "sites");
if (!scope.allowed.some((s) => s.id === id)) notFound();
const isAdmin = (await getCurrentUser())?.role === "admin";
// Pull everything we need for the financial roll-up
const [
@ -347,6 +349,7 @@ export default async function SiteDetailPage({
latitude: site.latitude ?? null,
longitude: site.longitude ?? null,
}}
canEditProjectCode={isAdmin}
/>
{siteMap && (
<Link href={`/dashboard/surveying/maps/${id}/map`}>

View file

@ -72,11 +72,12 @@ export function AddSiteButton({
);
}
export function EditSiteButton({ site }: { site: SiteFormInitial }) {
export function EditSiteButton({ site, canEditProjectCode = false }: { site: SiteFormInitial; canEditProjectCode?: boolean }) {
return (
<SiteFormDialog
mode="edit"
initial={site}
canEditProjectCode={canEditProjectCode}
trigger={
<Button
type="button"
@ -98,17 +99,22 @@ function SiteFormDialog({
initial,
templates,
trigger,
canEditProjectCode = false,
}: {
mode: Mode;
initial?: SiteFormInitial;
templates?: SiteTemplateOption[];
trigger: React.ReactNode;
/** Admins can unlock a mistyped code by double-clicking it. Editing only. */
canEditProjectCode?: boolean;
}) {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [templateId, setTemplateId] = React.useState<string>("blank");
const [codeUnlocked, setCodeUnlocked] = React.useState(false);
const codeEditable = mode === "create" || codeUnlocked;
// Form state — strings everywhere so inputs stay editable; we coerce on submit.
const [form, setForm] = React.useState(() => initialValues(initial));
@ -154,11 +160,11 @@ function SiteFormDialog({
setError(null);
const projectCode = form.project_code.trim();
if (mode === "create" && !projectCode) {
if (codeEditable && !projectCode) {
setError("Project code is required");
return;
}
if (mode === "create" && projectCode.length > 64) {
if (codeEditable && projectCode.length > 64) {
setError("Project code must be 64 characters or fewer");
return;
}
@ -193,6 +199,7 @@ function SiteFormDialog({
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...(codeUnlocked && projectCode !== initial?.project_code ? { project_code: projectCode } : {}),
name: form.name.trim(),
duration: form.duration.trim() || null,
job_start_date: form.job_start_date || null,
@ -225,7 +232,9 @@ function SiteFormDialog({
const sub =
mode === "create"
? "Pick a template to clone the working-hours defaults, or start blank."
: "Update site details. Project code is permanent once a site exists.";
: canEditProjectCode
? "Update site details. Double-click the project code to correct it."
: "Update site details. Only an admin can change the project code.";
return (
<Dialog
@ -236,6 +245,7 @@ function SiteFormDialog({
setError(null);
setForm(initialValues(initial));
setTemplateId("blank");
setCodeUnlocked(false);
}
}}
>
@ -276,14 +286,28 @@ function SiteFormDialog({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field
label="Project code"
hint={mode === "edit" ? "Locked once set" : "Required · max 64 chars"}
hint={
codeEditable
? "Required · max 64 chars"
: canEditProjectCode
? "Double-click to change"
: "Admins only"
}
>
{/* readOnly, not disabled: a disabled input never receives the double-click. */}
<Input
value={form.project_code}
onChange={(e) => setField("project_code", e.target.value)}
disabled={mode === "edit"}
readOnly={!codeEditable}
aria-readonly={!codeEditable}
onDoubleClick={(e) => {
if (codeEditable || !canEditProjectCode) return;
setCodeUnlocked(true);
e.currentTarget.select();
}}
title={!codeEditable && canEditProjectCode ? "Double-click to change" : undefined}
placeholder="e.g. P-217"
className="font-mono"
className={cn("font-mono", !codeEditable && "cursor-default bg-muted text-muted-foreground")}
maxLength={64}
/>
</Field>