diff --git a/src/app/api/sites/[id]/model/files/[name]/route.ts b/src/app/api/sites/[id]/model/files/[name]/route.ts new file mode 100644 index 0000000..a389464 --- /dev/null +++ b/src/app/api/sites/[id]/model/files/[name]/route.ts @@ -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(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" } }); +} diff --git a/src/app/api/sites/[id]/model/route.test.ts b/src/app/api/sites/[id]/model/route.test.ts new file mode 100644 index 0000000..ccdf1cd --- /dev/null +++ b/src/app/api/sites/[id]/model/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/sites/[id]/model/route.ts b/src/app/api/sites/[id]/model/route.ts new file mode 100644 index 0000000..3f0387d --- /dev/null +++ b/src/app/api/sites/[id]/model/route.ts @@ -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 }); +} diff --git a/src/app/api/sites/[id]/progress/route.test.ts b/src/app/api/sites/[id]/progress/route.test.ts new file mode 100644 index 0000000..765c23d --- /dev/null +++ b/src/app/api/sites/[id]/progress/route.test.ts @@ -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[], batchSend: vi.fn(), +})); +vi.mock("@/lib/auth/session", () => ({ getCurrentUser: m.user })); +vi.mock("@/lib/site-map/server", async (orig) => ({ + ...(await orig()), + 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) => { 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" })]); + }); +}); diff --git a/src/app/api/sites/[id]/progress/route.ts b/src/app/api/sites/[id]/progress/route.ts new file mode 100644 index 0000000..f3d9aab --- /dev/null +++ b/src/app/api/sites/[id]/progress/route.ts @@ -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(); + 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) }); +}