import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, newId, normalizeName, parsePartialDate } from "@dci/core"; import { envelope, notFound, parseBody, badRequest } from "../../lib/http.js"; import { pg } from "../../lib/sql.js"; import { str, type Row } from "../../lib/rows.js"; import { ensureManualSource, mergeFacility, setFacilityParent } from "../../repositories/admin/merge.js"; import { getFacilityDetail } from "../../repositories/facilities.js"; import { invalidate } from "../../cache.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(), facility_type: z.enum(FACILITY_TYPES).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(), it_capacity_mw: z.number().positive().max(10000).nullable().optional(), total_power_mw: z.number().positive().max(10000).nullable().optional(), planned_power_mw: z.number().positive().max(20000).nullable().optional(), mw_is_estimate: z.boolean().optional(), opened_on: partialDate.optional(), operator_id: z.string().nullable().optional(), is_ai: z.boolean().optional(), is_hyperscale: z.boolean().optional(), description: z.string().max(5000).nullable().optional(), note: z.string().max(500).optional(), }).strict(); const COLUMN_TO_FIELD: Record = { it_capacity_mw: "itCapacityMw", total_power_mw: "totalPowerMw", planned_power_mw: "plannedPowerMw", facility_type: "facilityType", geo_precision: "geoPrecision", opened_on: "openedOn", operator_id: "operatorId", is_ai: "isAi", is_hyperscale: "isHyperscale", mw_is_estimate: "mwIsEstimate" }; export async function curationAdminRoutes(app: FastifyInstance): Promise { app.patch("/facilities/:id", { schema: { summary: "Curate facility fields; writes src_manual provenance + facility_updated event" } }, 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 == null) !== (fields.lng == null) && (fields.lat !== undefined || fields.lng !== undefined)) throw badRequest("lat and lng must be set together"); if (fields.lat != null && fields.lng != null && (fields.geo_precision === "exact" || fields.geo_precision === undefined) && body.geo_precision === undefined) { // coordinates entered by hand default to "approximate" unless a precision is given explicitly fields.geo_precision = "approximate"; } const sql = pg(); const cur = (await sql`select * from facilities where id = ${id} or slug = ${id} limit 1`)[0]; if (!cur) throw notFound("facility"); const facilityId = String(cur.id); if (fields.operator_id) { const op = await sql`select id from operators where id = ${fields.operator_id}`; if (!op.length) throw badRequest("operator_id does not exist"); } await ensureManualSource(); const now = new Date().toISOString(); const changed: Array<{ field: string; column: string; oldValue: unknown; newValue: unknown }> = []; const set: Record = {}; for (const [col, val] of Object.entries(fields)) { if (val === undefined) continue; const before = cur[col] ?? null; if (JSON.stringify(before) === JSON.stringify(val)) continue; set[col] = val; changed.push({ field: COLUMN_TO_FIELD[col] ?? col, column: col, oldValue: before, newValue: val }); } if (!changed.length) return envelope({ id: facilityId, changed: [], message: "no changes" }); if (set.name) set.normalized_name = normalizeName(String(set.name)); set.updated_at = now; set.last_verified = now; if (set.status !== undefined || set.it_capacity_mw !== undefined || set.total_power_mw !== undefined) set.confidence = "high"; await sql.begin(async (tx) => { await tx`update facilities set ${tx(set)} where id = ${facilityId}`; for (const c of changed) { await tx`insert into provenance (id, entity_type, entity_id, field, value, source_id, connector_id, document_id, url, first_observed, last_observed, retrieved_at, confidence, is_estimate, method, extractor_version, is_current, note) values (${newId("provenance")}, 'facility', ${facilityId}, ${c.field}, ${JSON.stringify(c.newValue)}::jsonb, 'src_manual', 'manual', null, ${"https://www.datacenterindex.io/admin/facilities/" + facilityId}, ${now}, ${now}, ${now}, 'high', ${c.column === "mw_is_estimate" ? false : Boolean(fields.mw_is_estimate) && /mw$/.test(c.column)}, 'manual', 'admin', true, ${note ?? null}) on conflict (entity_type, entity_id, field, source_id, url) do update set value = excluded.value, last_observed = excluded.last_observed, retrieved_at = excluded.retrieved_at, is_current = true, is_estimate = excluded.is_estimate, note = excluded.note`; } const digest = createHash("sha256").update(JSON.stringify(changed.map((c) => [c.field, c.newValue]))).digest("hex").slice(0, 16); const statusChange = changed.find((c) => c.column === "status"); const mwChange = changed.find((c) => /mw$/.test(c.column) && c.column !== "mw_is_estimate"); const eventType = statusChange ? "status_changed" : mwChange ? (mwChange.column === "planned_power_mw" ? "planned_capacity_changed" : "capacity_changed") : "facility_updated"; const significance = statusChange ? 90 : mwChange ? 80 : 30; await tx`insert into events (id, entity_type, entity_id, event_type, detected_at, effective_date, old_value, new_value, source_id, document_id, url, title, summary, significance, confidence, review_status, country_iso2, operator_id, fingerprint) values (${newId("event")}, 'facility', ${facilityId}, ${eventType}, ${now}, ${now.slice(0, 10)}, ${JSON.stringify(Object.fromEntries(changed.map((c) => [c.field, c.oldValue])))}::jsonb, ${JSON.stringify(Object.fromEntries(changed.map((c) => [c.field, c.newValue])))}::jsonb, 'src_manual', null, ${"https://www.datacenterindex.io/datacenters/" + String(cur.slug)}, ${`${String(set.name ?? cur.name)}: ${changed.map((c) => c.field).join(", ")} updated by curation`}, ${note ?? null}, ${significance}, 'high', 'approved', ${str(cur.country_iso2)}, ${(set.operator_id as string | undefined) ?? str(cur.operator_id)}, ${"manual:" + facilityId + ":" + now.slice(0, 10) + ":" + digest}) on conflict (fingerprint) do nothing`; }); await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map"), invalidate("/events")]); const detail = await getFacilityDetail(facilityId); return envelope({ id: facilityId, changed, facility: detail?.detail ?? null }); }); app.post("/facilities/:id/merge", { schema: { summary: "Merge this facility into another {into}" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ into: z.string().min(1) }), req.body); const sql = pg(); const [dup, into] = await Promise.all([sql`select id from facilities where id = ${id} or slug = ${id} limit 1`, sql`select id from facilities where id = ${body.into} or slug = ${body.into} limit 1`]); if (!dup[0]) throw notFound("facility"); if (!into[0]) throw badRequest("target facility not found"); await ensureManualSource(); let res: Awaited>; try { res = await mergeFacility(String(dup[0].id), String(into[0].id)); } catch (e) { throw badRequest((e as Error).message); } await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map")]); return envelope(res); }); app.post("/facilities/:id/parent", { schema: { summary: "Containment link {parentId | null}: the facility becomes a building of the parent campus (record_scope building / campus); aggregates never count both" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ parentId: z.string().min(1).nullable() }), req.body); const res = await setFacilityParent(id, body.parentId); await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map")]); return envelope(res); }); app.patch("/events/:id", { schema: { summary: "Review an event {reviewStatus: auto|pending|approved|rejected}" } }, async (req) => { const { id } = req.params as { id: string }; const body = parseBody(z.object({ reviewStatus: z.enum(["auto", "pending", "approved", "rejected"]) }), req.body); const sql = pg(); const rows = await sql`update events set review_status = ${body.reviewStatus} where id = ${id} returning id, review_status`; if (!rows[0]) throw notFound("event"); await invalidate("/events"); return envelope({ id, reviewStatus: rows[0].review_status }); }); }