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:
parent
4a2e39be86
commit
607de20232
4 changed files with 109 additions and 16 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue