feat(site-map): model and progress API routes

This commit is contained in:
DEls 2026-10-14 07:07:57 +02:00
parent 226e3e6fcb
commit a2db3a2b13
5 changed files with 207 additions and 0 deletions

View file

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireSiteMapUser } from "@/lib/site-map/auth";
import { fetchModelFile, getActiveModel } from "@/lib/site-map/server";
import { MODEL_FILES, type ModelFileName } from "@/lib/site-map/model-types";
const NAMES = new Set<string>(Object.values(MODEL_FILES));
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; name: string }> }) {
const { id, name } = await params;
const user = await requireSiteMapUser(id, "view");
if (user instanceof Response) return user;
if (!NAMES.has(name)) return NextResponse.json({ error: "unknown_file" }, { status: 404 });
const pb = await pbAdmin();
const model = await getActiveModel(pb, id);
if (!model) return NextResponse.json({ error: "no_model" }, { status: 404 });
const upstream = await fetchModelFile(pb, model, name as ModelFileName);
if (!upstream.ok || !upstream.body) return NextResponse.json({ error: "model_file_unavailable", status: upstream.status }, { status: upstream.status === 404 ? 404 : 502 });
return new Response(upstream.body, { headers: { "Content-Type": "application/json", "Cache-Control": "private, max-age=3600" } });
}

View file

@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
const user = vi.hoisted(() => vi.fn(async () => ({ id: "u", email: "", name: "A", role: "site_user", siteIds: ["hills"], mustChangePassword: false })));
vi.mock("@/lib/auth/session", () => ({ getCurrentUser: user }));
vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({})) }));
vi.mock("@/lib/site-map/server", () => ({ getActiveModel: vi.fn(async (_pb: unknown, site: string) => site === "hills" ? { id: "m1", label: "Rev 1", files: [], stats: { sections: 22 } } : null) }));
import { GET } from "./route";
describe("GET model", () => {
it("returns metadata and file URLs for the active model", async () => {
const r = await GET(new Request("http://x"), { params: Promise.resolve({ id: "hills" }) });
const body = await r.json();
expect(body.files.road).toBe("/api/sites/hills/model/files/bushveld?v=m1");
expect(body.files.surface).toBe("/api/sites/hills/model/files/surface?v=m1");
});
it("404s when the user may not see the site", async () => {
expect((await GET(new Request("http://x"), { params: Promise.resolve({ id: "other" }) })).status).toBe(404);
});
});

View file

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireSiteMapUser } from "@/lib/site-map/auth";
import { getActiveModel } from "@/lib/site-map/server";
import { MODEL_FILES } from "@/lib/site-map/model-types";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await requireSiteMapUser(id, "view");
if (user instanceof Response) return user;
const model = await getActiveModel(await pbAdmin(), id);
if (!model) return NextResponse.json({ error: "no_model" }, { status: 404 });
const files = Object.fromEntries(Object.entries(MODEL_FILES).map(([part, base]) => [part, `/api/sites/${id}/model/files/${base}?v=${model.id}`]));
return NextResponse.json({ id: model.id, label: model.label, built_at: model.built_at ?? null, stats: model.stats ?? {}, files });
}

View file

@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const m = vi.hoisted(() => ({
user: vi.fn(), events: [] as { key: string; value: number; user_name: string; created: string }[],
created: [] as Record<string, unknown>[], batchSend: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getCurrentUser: m.user }));
vi.mock("@/lib/site-map/server", async (orig) => ({
...(await orig<typeof import("@/lib/site-map/server")>()),
getActiveModel: vi.fn(async () => ({ id: "model1", site: "hills", files: [] })),
getKeyIndex: vi.fn(async () => {
const { modelKeyIndex } = await import("@/lib/site-map/keys");
const { fixture } = await import("@/lib/site-map/__fixtures__/mini-model");
return modelKeyIndex(fixture);
}),
loadProgressState: vi.fn(async () => {
const { latestState } = await import("@/lib/site-map/progress");
return latestState(m.events);
}),
}));
vi.mock("@/lib/pb", () => ({
pbAdmin: vi.fn(async () => ({
createBatch: () => ({
collection: () => ({ create: (row: Record<string, unknown>) => { m.created.push(row); } }),
send: async () => {
for (const row of m.created) m.events.push({ key: row.key as string, value: row.value as number, user_name: row.user_name as string, created: `2026-10-14 10:00:${String(m.events.length).padStart(2, "0")}.000Z` });
m.batchSend(); return [];
},
}),
})),
}));
import { GET, POST } from "./route";
const ctx = { params: Promise.resolve({ id: "hills" }) };
const post = (body: unknown) => POST(new Request("http://x/api/sites/hills/progress", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), ctx);
const person = (role: string, extra = {}) => ({ id: `u-${role}`, email: "a@b.c", name: role, role, siteIds: ["hills"], mustChangePassword: false, ...extra });
describe("site progress API", () => {
beforeEach(() => { m.events = []; m.created = []; vi.clearAllMocks(); m.user.mockResolvedValue(person("site_user")); });
it("saves events and returns the new state", async () => {
const r = await post({ model: "model1", events: [{ key: "peg:BJ1", value: 2 }] });
expect(r.status).toBe(200);
expect((await r.json()).state["peg:BJ1"]).toMatchObject({ value: 2, user_name: "site_user" });
expect(m.created[0]).toMatchObject({ site: "hills", model: "model1", key: "peg:BJ1", value: 2, source: "manual", user: "u-site_user" });
});
it("writes only the last value when a key repeats in one request", async () => {
await post({ model: "model1", events: [{ key: "paint", value: 1 }, { key: "paint", value: 0 }] });
expect(m.created).toHaveLength(1);
expect(m.created[0]).toMatchObject({ key: "paint", value: 0 });
});
it("lets the later of two users win and shows who changed it", async () => {
await post({ model: "model1", events: [{ key: "paint", value: 1 }] });
m.user.mockResolvedValue(person("admin", { name: "Boss" }));
await post({ model: "model1", events: [{ key: "paint", value: 0 }] });
const state = (await (await GET(new Request("http://x"), ctx)).json()).state;
expect(state.paint).toMatchObject({ value: 0, user_name: "Boss" });
});
it("refuses a stale model with 409", async () => {
const r = await post({ model: "old", events: [{ key: "paint", value: 1 }] });
expect(r.status).toBe(409);
expect(await r.json()).toEqual({ error: "stale_model", model: "model1" });
});
it("rejects unknown keys and bad values with a field error", async () => {
const r = await post({ model: "model1", events: [{ key: "paint", value: 1 }, { key: "peg:BJ1", value: 7 }] });
expect(r.status).toBe(400);
expect(await r.json()).toMatchObject({ error: "validation", field: "events[1]" });
expect(m.created).toHaveLength(0);
});
it("rejects more than 200 events", async () => {
const r = await post({ model: "model1", events: Array.from({ length: 201 }, () => ({ key: "paint", value: 1 })) });
expect(r.status).toBe(400);
});
it("is view-only for office users", async () => {
m.user.mockResolvedValue(person("office_user", { modules: ["sites"] }));
expect((await GET(new Request("http://x"), ctx)).status).toBe(200);
expect((await post({ model: "model1", events: [{ key: "paint", value: 1 }] })).status).toBe(403);
});
it("returns 404 for a site the user is not on", async () => {
m.user.mockResolvedValue(person("site_user", { siteIds: ["other"] }));
expect((await post({ model: "model1", events: [{ key: "paint", value: 1 }] })).status).toBe(404);
});
it("allows clear only for admins and resets every non-zero item", async () => {
m.events = [{ key: "paint", value: 1, user_name: "x", created: "2026-10-14 08:00:00.000Z" }, { key: "peg:BJ1", value: 0, user_name: "x", created: "2026-10-14 08:00:01.000Z" }];
expect((await post({ model: "model1", clear: true })).status).toBe(403);
m.user.mockResolvedValue(person("admin"));
const r = await post({ model: "model1", clear: true });
expect(r.status).toBe(200);
expect(m.created).toEqual([expect.objectContaining({ key: "paint", value: 0, source: "admin_clear" })]);
});
});

View file

@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireSiteMapUser } from "@/lib/site-map/auth";
import { getActiveModel, getKeyIndex, loadProgressState } from "@/lib/site-map/server";
import { validateEvent } from "@/lib/site-map/keys";
type Ctx = { params: Promise<{ id: string }> };
const MAX_EVENTS = 200;
const bad = (field: string, message: string) => NextResponse.json({ error: "validation", field, message }, { status: 400 });
export async function GET(_request: Request, { params }: Ctx) {
const { id } = await params;
const user = await requireSiteMapUser(id, "view");
if (user instanceof Response) return user;
const pb = await pbAdmin();
const model = await getActiveModel(pb, id);
if (!model) return NextResponse.json({ error: "no_model" }, { status: 404 });
return NextResponse.json({ model: model.id, state: await loadProgressState(pb, id) });
}
export async function POST(request: Request, { params }: Ctx) {
const { id } = await params;
const user = await requireSiteMapUser(id, "edit");
if (user instanceof Response) return user;
let body: { model?: unknown; events?: unknown; clear?: unknown };
try { body = await request.json(); } catch { return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); }
const pb = await pbAdmin();
const model = await getActiveModel(pb, id);
if (!model) return NextResponse.json({ error: "no_model" }, { status: 404 });
if (body.model !== model.id) return NextResponse.json({ error: "stale_model", model: model.id }, { status: 409 });
let events: { key: string; value: number }[];
let source: "manual" | "admin_clear" = "manual";
if (body.clear === true) {
if (user.role !== "admin") return NextResponse.json({ error: "Only admins can clear progress" }, { status: 403 });
const state = await loadProgressState(pb, id);
events = Object.entries(state).filter(([, e]) => e.value !== 0).map(([key]) => ({ key, value: 0 }));
source = "admin_clear";
} else {
if (!Array.isArray(body.events) || body.events.length === 0) return bad("events", "Send at least one event.");
if (body.events.length > MAX_EVENTS) return bad("events", `At most ${MAX_EVENTS} events per request.`);
const index = await getKeyIndex(pb, model);
const last = new Map<string, number>();
for (const [i, e] of (body.events as unknown[]).entries()) {
const key = (e as { key?: unknown })?.key, value = (e as { value?: unknown })?.value;
if (typeof key !== "string") return bad(`events[${i}]`, "key must be text");
const err = validateEvent(index, key, value);
if (err) return bad(`events[${i}]`, err);
last.delete(key); last.set(key, value as number); // keep the last value per key, in order
}
events = [...last].map(([key, value]) => ({ key, value }));
}
if (events.length) {
const batch = pb.createBatch();
for (const e of events) {
batch.collection("site_progress_events").create({ site: id, model: model.id, key: e.key, value: e.value, user: user.id, user_name: user.name, source });
}
await batch.send();
}
return NextResponse.json({ saved: events.length, state: await loadProgressState(pb, id) });
}