spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/** Project curation routes: hide / unhide / merge / PATCH. */2import type { FastifyInstance } from "fastify";3import { z } from "zod";4import { AI_EVIDENCE_LEVELS, FACILITY_STATUSES, GEO_PRECISIONS, PROJECT_CLASSES, parsePartialDate } from "@dci/core";5import { envelope, notFound, parseBody, badRequest } from "../../lib/http.js";6import { pg } from "../../lib/sql.js";7import { invalidate } from "../../cache.js";8import { mergeProject, patchProject, setProjectHidden } from "../../repositories/admin/projects.js";910const partialDate = z.string().max(20).nullable().refine((v) => v == null || parsePartialDate(v) != null, "not a (partial) date");1112const patchSchema = z.object({13 name: z.string().min(2).max(200).optional(),14 status: z.enum(FACILITY_STATUSES).optional(),15 planned_mw: z.number().positive().max(20000).nullable().optional(),16 investment_usd: z.number().positive().nullable().optional(),17 expected_opening: partialDate.optional(),18 announced_on: partialDate.optional(),19 construction_started_on: partialDate.optional(),20 approved_on: partialDate.optional(),21 permit_filed_on: partialDate.optional(),22 opened_on: partialDate.optional(),23 operator_id: z.string().nullable().optional(),24 country_iso2: z.string().length(2).nullable().optional(),25 metro_id: z.string().nullable().optional(),26 city: z.string().max(200).nullable().optional(),27 lat: z.number().min(-90).max(90).nullable().optional(),28 lng: z.number().min(-180).max(180).nullable().optional(),29 geo_precision: z.enum(GEO_PRECISIONS).optional(),30 project_class: z.enum(PROJECT_CLASSES).nullable().optional(),31 ai_evidence: z.enum(AI_EVIDENCE_LEVELS).optional(),32 is_ai: z.boolean().optional(),33 description: z.string().max(5000).nullable().optional(),34 note: z.string().max(500).optional(),35}).strict();3637const CACHE_PREFIXES = ["/projects", "/dashboard", "/map", "/explore", "/pulse"];38async function bust(): Promise<void> { await Promise.all(CACHE_PREFIXES.map((p) => invalidate(p))); }3940const 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();4142export async function projectAdminRoutes(app: FastifyInstance): Promise<void> {43 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) => {44 const q = listSchema.parse(req.query ?? {});45 const sql = pg();46 const perPage = q.per_page ?? 50, page = q.page ?? 1;47 const like = q.q ? `%${q.q.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null;48 const hiddenCond = q.hidden === "true" ? sql`and p.hidden` : q.hidden === "false" ? sql`and not p.hidden and p.merged_into is null` : sql``;49 const rows = await sql`50 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,51 o.name as operator_name, o.slug as operator_slug,52 (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,53 (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,54 count(*) over() as total55 from projects p left join operators o on o.id = p.operator_id56 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``}57 order by p.review_priority desc, p.updated_at desc limit ${perPage} offset ${(page - 1) * perPage}`;58 const total = rows.length ? Number(rows[0]!.total) : 0;59 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 });60 });6162 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) => {63 const { id } = req.params as { id: string };64 const body = parseBody(z.object({ reason: z.string().min(1).max(500) }), req.body);65 const res = await setProjectHidden(id, true, body.reason);66 if (!res) throw notFound("project");67 await bust();68 return envelope(res);69 });7071 app.post("/projects/:id/unhide", { schema: { summary: "Restore a hidden project {reason?}" } }, async (req) => {72 const { id } = req.params as { id: string };73 const body = parseBody(z.object({ reason: z.string().max(500).optional() }), req.body ?? {});74 const res = await setProjectHidden(id, false, body.reason ?? null);75 if (!res) throw notFound("project");76 await bust();77 return envelope(res);78 });7980 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) => {81 const { id } = req.params as { id: string };82 const body = parseBody(z.object({ into: z.string().min(1) }), req.body);83 let res: Awaited<ReturnType<typeof mergeProject>>;84 try { res = await mergeProject(id, body.into); } catch (e) {85 const msg = (e as Error).message;86 if (msg === "project not found") throw notFound("project");87 throw badRequest(msg);88 }89 await bust();90 return envelope(res);91 });9293 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) => {94 const { id } = req.params as { id: string };95 const body = parseBody(patchSchema, req.body);96 const { note, ...fields } = body;97 const entries = Object.entries(fields).filter(([, v]) => v !== undefined);98 if (!entries.length) throw badRequest("no fields to update");99 if ((fields.lat === undefined) !== (fields.lng === undefined) || (fields.lat == null) !== (fields.lng == null)) throw badRequest("lat and lng must be set together");100 const sql = pg();101 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"); }102 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"); }103 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"); }104 const res = await patchProject(id, fields, note ?? null);105 if (!res) throw notFound("project");106 if (res.changed.length) await bust();107 return envelope({ ...res, message: res.changed.length ? undefined : "no changes" });108 });109}110