/** Project curation routes: hide / unhide / merge / PATCH. */ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { AI_EVIDENCE_LEVELS, FACILITY_STATUSES, GEO_PRECISIONS, PROJECT_CLASSES, parsePartialDate } from "@dci/core"; import { envelope, notFound, parseBody, badRequest } from "../../lib/http.js"; import { pg } from "../../lib/sql.js"; import { invalidate } from "../../cache.js"; import { mergeProject, patchProject, setProjectHidden } from "../../repositories/admin/projects.js"; const partialDate = z.string().max(20).nullable().refine((v) => v == null || parsePartialDate(v) != null, "not a (partial) date"); const patchSchema = z.object({ name: z.string().min(2).max(200).optional(), status: z.enum(FACILITY_STATUSES).optional(), planned_mw: z.number().positive().max(20000).nullable().optional(), investment_usd: z.number().positive().nullable().optional(), expected_opening: partialDate.optional(), announced_on: partialDate.optional(), construction_started_on: partialDate.optional(), approved_on: partialDate.optional(), permit_filed_on: partialDate.optional(), opened_on: partialDate.optional(), operator_id: z.string().nullable().optional(), country_iso2: z.string().length(2).nullable().optional(), metro_id: z.string().nullable().optional(), city: z.string().max(200).nullable().optional(), lat: z.number().min(-90).max(90).nullable().optional(), lng: z.number().min(-180).max(180).nullable().optional(), geo_precision: z.enum(GEO_PRECISIONS).optional(), project_class: z.enum(PROJECT_CLASSES).nullable().optional(), ai_evidence: z.enum(AI_EVIDENCE_LEVELS).optional(), is_ai: z.boolean().optional(), description: z.string().max(5000).nullable().optional(), note: z.string().max(500).optional(), }).strict(); const CACHE_PREFIXES = ["/projects", "/dashboard", "/map", "/explore", "/pulse"]; async function bust(): Promise { await Promise.all(CACHE_PREFIXES.map((p) => invalidate(p))); } const listSchema = z.object({ hidden: z.enum(["true", "false", "all"]).optional(), q: z.string().max(200).optional(), min_priority: z.coerce.number().int().min(0).max(100).optional(), page: z.coerce.number().int().min(1).optional(), per_page: z.coerce.number().int().min(1).max(200).optional() }).strict(); export async function projectAdminRoutes(app: FastifyInstance): Promise { app.get("/projects", { schema: { summary: "Admin project list: hidden=true|false|all (default all), q, min_priority, page, per_page — includes hidden / merged rows, review priority, class, evidence, open flags" } }, async (req) => { const q = listSchema.parse(req.query ?? {}); const sql = pg(); const perPage = q.per_page ?? 50, page = q.page ?? 1; const like = q.q ? `%${q.q.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null; const hiddenCond = q.hidden === "true" ? sql`and p.hidden` : q.hidden === "false" ? sql`and not p.hidden and p.merged_into is null` : sql``; const rows = await sql` select p.id, p.slug, p.name, p.status, p.planned_mw, p.investment_usd, p.country_iso2, p.city, p.project_class, p.evidence_level, p.ai_evidence, p.capacity_scope, p.investment_scope, p.hidden, p.merged_into, p.review_priority, p.source_url, p.announced_on, p.updated_at, o.name as operator_name, o.slug as operator_slug, (select count(*)::int from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open') as open_flags, (select json_agg(json_build_object('code', f.code, 'severity', f.severity, 'message', f.message)) from (select code, severity, message from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open' order by priority desc limit 5) f) as flags, count(*) over() as total from projects p left join operators o on o.id = p.operator_id where true ${hiddenCond} ${like ? sql`and (p.name ilike ${like} or p.slug ilike ${like})` : sql``} ${q.min_priority != null ? sql`and p.review_priority >= ${q.min_priority}` : sql``} order by p.review_priority desc, p.updated_at desc limit ${perPage} offset ${(page - 1) * perPage}`; const total = rows.length ? Number(rows[0]!.total) : 0; return envelope(rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, status: r.status, plannedMw: r.planned_mw == null ? null : Number(r.planned_mw), investmentUsd: r.investment_usd == null ? null : Number(r.investment_usd), countryIso2: r.country_iso2, city: r.city, projectClass: r.project_class, evidenceLevel: r.evidence_level, aiEvidence: r.ai_evidence, capacityScope: r.capacity_scope, investmentScope: r.investment_scope, hidden: Boolean(r.hidden), mergedInto: r.merged_into, reviewPriority: Number(r.review_priority ?? 0), sourceUrl: r.source_url, announcedOn: r.announced_on, updatedAt: r.updated_at, operator: r.operator_name ? { name: r.operator_name, slug: r.operator_slug } : null, openFlags: Number(r.open_flags ?? 0), flags: r.flags ?? [] })), { total, page, perPage }); }); app.post("/projects/:id/hide", { schema: { summary: "Hide a project (false positive kept for audit, never listed) {reason}; writes src_manual provenance + a project_status_changed event {hidden}" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ reason: z.string().min(1).max(500) }), req.body); const res = await setProjectHidden(id, true, body.reason); if (!res) throw notFound("project"); await bust(); return envelope(res); }); app.post("/projects/:id/unhide", { schema: { summary: "Restore a hidden project {reason?}" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ reason: z.string().max(500).optional() }), req.body ?? {}); const res = await setProjectHidden(id, false, body.reason ?? null); if (!res) throw notFound("project"); await bust(); return envelope(res); }); app.post("/projects/:id/merge", { schema: { summary: "Merge this project into another {into}: events, timeline, claims, provenance, entity keys move to the survivor; the duplicate keeps merged_into" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ into: z.string().min(1) }), req.body); let res: Awaited>; try { res = await mergeProject(id, body.into); } catch (e) { const msg = (e as Error).message; if (msg === "project not found") throw notFound("project"); throw badRequest(msg); } await bust(); return envelope(res); }); app.patch("/projects/:id", { schema: { summary: "Curate project fields (strict body); src_manual provenance on every field; events only for status (project_status_changed) and planned_mw (planned_capacity_changed)" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(patchSchema, req.body); const { note, ...fields } = body; const entries = Object.entries(fields).filter(([, v]) => v !== undefined); if (!entries.length) throw badRequest("no fields to update"); if ((fields.lat === undefined) !== (fields.lng === undefined) || (fields.lat == null) !== (fields.lng == null)) throw badRequest("lat and lng must be set together"); const sql = pg(); if (fields.operator_id) { const r = await sql`select id from operators where id = ${fields.operator_id}`; if (!r.length) throw badRequest("operator_id does not exist"); } if (fields.country_iso2) { fields.country_iso2 = fields.country_iso2.toUpperCase(); const r = await sql`select iso2 from countries where iso2 = ${fields.country_iso2}`; if (!r.length) throw badRequest("country_iso2 does not exist"); } if (fields.metro_id) { const r = await sql`select id from metros where id = ${fields.metro_id}`; if (!r.length) throw badRequest("metro_id does not exist"); } const res = await patchProject(id, fields, note ?? null); if (!res) throw notFound("project"); if (res.changed.length) await bust(); return envelope({ ...res, message: res.changed.length ? undefined : "no changes" }); }); }