feat(site-map): model and progress API routes
This commit is contained in:
parent
226e3e6fcb
commit
a2db3a2b13
5 changed files with 207 additions and 0 deletions
20
src/app/api/sites/[id]/model/files/[name]/route.ts
Normal file
20
src/app/api/sites/[id]/model/files/[name]/route.ts
Normal 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" } });
|
||||
}
|
||||
18
src/app/api/sites/[id]/model/route.test.ts
Normal file
18
src/app/api/sites/[id]/model/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
src/app/api/sites/[id]/model/route.ts
Normal file
15
src/app/api/sites/[id]/model/route.ts
Normal 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 });
|
||||
}
|
||||
91
src/app/api/sites/[id]/progress/route.test.ts
Normal file
91
src/app/api/sites/[id]/progress/route.test.ts
Normal 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" })]);
|
||||
});
|
||||
});
|
||||
63
src/app/api/sites/[id]/progress/route.ts
Normal file
63
src/app/api/sites/[id]/progress/route.ts
Normal 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) });
|
||||
}
|
||||
Loading…
Reference in a new issue