diff --git a/src/app/api/sites/[id]/route.test.ts b/src/app/api/sites/[id]/route.test.ts index 11fff42..f0962c6 100644 --- a/src/app/api/sites/[id]/route.test.ts +++ b/src/app/api/sites/[id]/route.test.ts @@ -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) => 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"); + }); +}); diff --git a/src/app/api/sites/[id]/route.ts b/src/app/api/sites/[id]/route.ts index cd7a311..a4ebfab 100644 --- a/src/app/api/sites/[id]/route.ts +++ b/src/app/api/sites/[id]/route.ts @@ -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 = {}; + 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 = {}; 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 }); diff --git a/src/app/dashboard/sites/[id]/page.tsx b/src/app/dashboard/sites/[id]/page.tsx index 07579c2..bc63912 100644 --- a/src/app/dashboard/sites/[id]/page.tsx +++ b/src/app/dashboard/sites/[id]/page.tsx @@ -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 && ( diff --git a/src/components/site-form-dialog.tsx b/src/components/site-form-dialog.tsx index 42554df..64238ca 100644 --- a/src/components/site-form-dialog.tsx +++ b/src/components/site-form-dialog.tsx @@ -72,11 +72,12 @@ export function AddSiteButton({ ); } -export function EditSiteButton({ site }: { site: SiteFormInitial }) { +export function EditSiteButton({ site, canEditProjectCode = false }: { site: SiteFormInitial; canEditProjectCode?: boolean }) { return ( (null); const [templateId, setTemplateId] = React.useState("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 ( @@ -276,14 +286,28 @@ function SiteFormDialog({
+ {/* readOnly, not disabled: a disabled input never receives the double-click. */} 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} />