SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
9.1 KB · 121 lines typescript
Raw Blame History
1import { createHash } from "node:crypto";2import type { FastifyInstance } from "fastify";3import { z } from "zod";4import { FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, newId, normalizeName, parsePartialDate } from "@dci/core";5import { envelope, notFound, parseBody, badRequest } from "../../lib/http.js";6import { pg } from "../../lib/sql.js";7import { str, type Row } from "../../lib/rows.js";8import { ensureManualSource, mergeFacility, setFacilityParent } from "../../repositories/admin/merge.js";9import { getFacilityDetail } from "../../repositories/facilities.js";10import { invalidate } from "../../cache.js";1112const partialDate = z.string().max(20).nullable().refine((v) => v == null || parsePartialDate(v) != null, "not a (partial) date");1314const patchSchema = z.object({15  name: z.string().min(2).max(200).optional(),16  status: z.enum(FACILITY_STATUSES).optional(),17  facility_type: z.enum(FACILITY_TYPES).optional(),18  lat: z.number().min(-90).max(90).nullable().optional(),19  lng: z.number().min(-180).max(180).nullable().optional(),20  geo_precision: z.enum(GEO_PRECISIONS).optional(),21  it_capacity_mw: z.number().positive().max(10000).nullable().optional(),22  total_power_mw: z.number().positive().max(10000).nullable().optional(),23  planned_power_mw: z.number().positive().max(20000).nullable().optional(),24  mw_is_estimate: z.boolean().optional(),25  opened_on: partialDate.optional(),26  operator_id: z.string().nullable().optional(),27  is_ai: z.boolean().optional(),28  is_hyperscale: z.boolean().optional(),29  description: z.string().max(5000).nullable().optional(),30  note: z.string().max(500).optional(),31}).strict();3233const COLUMN_TO_FIELD: Record<string, string> = { 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" };3435export async function curationAdminRoutes(app: FastifyInstance): Promise<void> {36  app.patch("/facilities/:id", { schema: { summary: "Curate facility fields; writes src_manual provenance + facility_updated event" } }, async (req) => {37    const { id } = req.params as { id: string };38    const body = parseBody(patchSchema, req.body);39    const { note, ...fields } = body;40    const entries = Object.entries(fields).filter(([, v]) => v !== undefined);41    if (!entries.length) throw badRequest("no fields to update");42    if ((fields.lat == null) !== (fields.lng == null) && (fields.lat !== undefined || fields.lng !== undefined)) throw badRequest("lat and lng must be set together");43    if (fields.lat != null && fields.lng != null && (fields.geo_precision === "exact" || fields.geo_precision === undefined) && body.geo_precision === undefined) {44      // coordinates entered by hand default to "approximate" unless a precision is given explicitly45      fields.geo_precision = "approximate";46    }47    const sql = pg();48    const cur = (await sql<Row[]>`select * from facilities where id = ${id} or slug = ${id} limit 1`)[0];49    if (!cur) throw notFound("facility");50    const facilityId = String(cur.id);51    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"); }52    await ensureManualSource();53    const now = new Date().toISOString();54    const changed: Array<{ field: string; column: string; oldValue: unknown; newValue: unknown }> = [];55    const set: Record<string, unknown> = {};56    for (const [col, val] of Object.entries(fields)) {57      if (val === undefined) continue;58      const before = cur[col] ?? null;59      if (JSON.stringify(before) === JSON.stringify(val)) continue;60      set[col] = val;61      changed.push({ field: COLUMN_TO_FIELD[col] ?? col, column: col, oldValue: before, newValue: val });62    }63    if (!changed.length) return envelope({ id: facilityId, changed: [], message: "no changes" });64    if (set.name) set.normalized_name = normalizeName(String(set.name));65    set.updated_at = now;66    set.last_verified = now;67    if (set.status !== undefined || set.it_capacity_mw !== undefined || set.total_power_mw !== undefined) set.confidence = "high";68    await sql.begin(async (tx) => {69      await tx`update facilities set ${tx(set)} where id = ${facilityId}`;70      for (const c of changed) {71        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)72          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})73          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`;74      }75      const digest = createHash("sha256").update(JSON.stringify(changed.map((c) => [c.field, c.newValue]))).digest("hex").slice(0, 16);76      const statusChange = changed.find((c) => c.column === "status");77      const mwChange = changed.find((c) => /mw$/.test(c.column) && c.column !== "mw_is_estimate");78      const eventType = statusChange ? "status_changed" : mwChange ? (mwChange.column === "planned_power_mw" ? "planned_capacity_changed" : "capacity_changed") : "facility_updated";79      const significance = statusChange ? 90 : mwChange ? 80 : 30;80      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)81        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})82        on conflict (fingerprint) do nothing`;83    });84    await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map"), invalidate("/events")]);85    const detail = await getFacilityDetail(facilityId);86    return envelope({ id: facilityId, changed, facility: detail?.detail ?? null });87  });8889  app.post("/facilities/:id/merge", { schema: { summary: "Merge this facility into another {into}" } }, async (req) => {90    const { id } = req.params as { id: string };91    const body = parseBody(z.object({ into: z.string().min(1) }), req.body);92    const sql = pg();93    const [dup, into] = await Promise.all([sql<Row[]>`select id from facilities where id = ${id} or slug = ${id} limit 1`, sql<Row[]>`select id from facilities where id = ${body.into} or slug = ${body.into} limit 1`]);94    if (!dup[0]) throw notFound("facility");95    if (!into[0]) throw badRequest("target facility not found");96    await ensureManualSource();97    let res: Awaited<ReturnType<typeof mergeFacility>>;98    try { res = await mergeFacility(String(dup[0].id), String(into[0].id)); } catch (e) { throw badRequest((e as Error).message); }99    await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map")]);100    return envelope(res);101  });102103  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) => {104    const { id } = req.params as { id: string };105    const body = parseBody(z.object({ parentId: z.string().min(1).nullable() }), req.body);106    const res = await setFacilityParent(id, body.parentId);107    await Promise.all([invalidate("/datacenters"), invalidate("/dashboard"), invalidate("/map")]);108    return envelope(res);109  });110111  app.patch("/events/:id", { schema: { summary: "Review an event {reviewStatus: auto|pending|approved|rejected}" } }, async (req) => {112    const { id } = req.params as { id: string };113    const body = parseBody(z.object({ reviewStatus: z.enum(["auto", "pending", "approved", "rejected"]) }), req.body);114    const sql = pg();115    const rows = await sql<Row[]>`update events set review_status = ${body.reviewStatus} where id = ${id} returning id, review_status`;116    if (!rows[0]) throw notFound("event");117    await invalidate("/events");118    return envelope({ id, reviewStatus: rows[0].review_status });119  });120}121