spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * Project curation: hide / unhide (false positives are never deleted), merge (duplicate folded into a survivor),3 * PATCH curated fields. Every change writes src_manual provenance; only lifecycle-relevant changes emit an event4 * (status → project_status_changed, planned MW → planned_capacity_changed, hide/unhide → project_status_changed with5 * {hidden} values). Everything else is provenance-only.6 */7import { createHash } from "node:crypto";8import type postgres from "postgres";9import { newId, normalizeName } from "@dci/core";10import { pg } from "../../lib/sql.js";11import { str, type Row } from "../../lib/rows.js";12import { ensureManualSource } from "./merge.js";1314const ADMIN_URL = (id: string) => `https://www.datacenterindex.io/admin/projects/${id}`;1516export async function findProject(idOrSlug: string): Promise<Row | null> {17 const sql = pg();18 return (await sql<Row[]>`select * from projects where id = ${idOrSlug} or slug = ${idOrSlug} order by (id = ${idOrSlug}) desc limit 1`)[0] ?? null;19}2021type Tx = postgres.TransactionSql;2223async function manualProvenance(tx: Tx, projectId: string, field: string, value: unknown, now: string, note: string | null, scope: string | null = null): Promise<void> {24 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, is_winner, scope, note)25 values (${newId("provenance")}, 'project', ${projectId}, ${field}, ${JSON.stringify(value)}::jsonb, 'src_manual', 'manual', null, ${ADMIN_URL(projectId)}, ${now}, ${now}, ${now}, 'high', false, 'manual', 'admin', true, true, ${scope}, ${note})26 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_winner = true, scope = excluded.scope, note = excluded.note`;27 // the manual value is the displayed one: other current rows for the field are no longer winners28 await tx`update provenance set is_winner = false where entity_type = 'project' and entity_id = ${projectId} and field = ${field} and source_id <> 'src_manual' and is_winner`;29}3031export async function setProjectHidden(idOrSlug: string, hidden: boolean, reason: string | null): Promise<{ id: string; hidden: boolean; changed: boolean } | null> {32 const sql = pg();33 const cur = await findProject(idOrSlug);34 if (!cur) return null;35 const id = String(cur.id);36 if (Boolean(cur.hidden) === hidden) return { id, hidden, changed: false };37 await ensureManualSource();38 const now = new Date().toISOString();39 await sql.begin(async (tx) => {40 await tx`update projects set hidden = ${hidden}, updated_at = now() where id = ${id}`;41 await manualProvenance(tx, id, "hidden", hidden, now, reason);42 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, project_id, fingerprint)43 values (${newId("event")}, 'project', ${id}, 'project_status_changed', ${now}, ${now.slice(0, 10)}, ${JSON.stringify({ hidden: !hidden })}::jsonb, ${JSON.stringify({ hidden })}::jsonb, 'src_manual', null, ${ADMIN_URL(id)}, ${`${String(cur.name)}: ${hidden ? "hidden by review" : "restored by review"}${reason ? ` (${reason})` : ""}`}, ${reason}, 10, 'high', 'approved', ${str(cur.country_iso2)}, ${str(cur.operator_id)}, ${id}, ${`manual:${hidden ? "hide" : "unhide"}:${id}:${now.slice(0, 10)}`})44 on conflict (fingerprint) do nothing`;45 });46 return { id, hidden, changed: true };47}4849export interface ProjectMergeResult { into: string; merged: string; moved: Record<string, number> }5051export async function mergeProject(dupIdOrSlug: string, intoIdOrSlug: string): Promise<ProjectMergeResult> {52 const sql = pg();53 const [dup, into] = await Promise.all([findProject(dupIdOrSlug), findProject(intoIdOrSlug)]);54 if (!dup) throw new Error("project not found");55 if (!into) throw new Error("target project not found");56 const dupId = String(dup.id), intoId = String(into.id);57 if (dupId === intoId) throw new Error("cannot merge a project into itself");58 if (into.hidden === true) throw new Error("target project is hidden");59 if (into.merged_into) throw new Error(`target is itself merged into ${String(into.merged_into)}`);60 if (dup.merged_into) throw new Error(`project is already merged into ${String(dup.merged_into)}`);61 await ensureManualSource();62 return sql.begin(async (tx) => {63 const moved: Record<string, number> = {};64 const count = (r: { count: number }) => r.count;65 moved.events = count(await tx`update events set project_id = ${intoId} where project_id = ${dupId}`);66 moved.eventsEntity = count(await tx`update events set entity_id = ${intoId} where entity_type = 'project' and entity_id = ${dupId}`);67 moved.timeline = count(await tx`update project_timeline set project_id = ${intoId} where project_id = ${dupId}`);68 moved.claims = count(await tx`update claims k set subject_id = ${intoId} where k.subject_type = 'project' and k.subject_id = ${dupId} and not exists (select 1 from claims q where q.subject_type = 'project' and q.subject_id = ${intoId} and q.predicate = k.predicate and q.source_id = k.source_id and q.url = k.url and coalesce(q.value, 0) = coalesce(k.value, 0) and coalesce(q.value_text, '') = coalesce(k.value_text, ''))`);69 await tx`delete from claims where subject_type = 'project' and subject_id = ${dupId}`;70 moved.provenance = count(await tx`update provenance p set entity_id = ${intoId} where p.entity_type = 'project' and p.entity_id = ${dupId} and not exists (select 1 from provenance q where q.entity_type = 'project' and q.entity_id = ${intoId} and q.field = p.field and q.source_id = p.source_id and q.url = p.url)`);71 await tx`delete from provenance where entity_type = 'project' and entity_id = ${dupId}`;72 moved.entityKeys = count(await tx`update entity_keys set entity_id = ${intoId} where entity_type = 'project' and entity_id = ${dupId}`);73 moved.qualityFlags = count(await tx`update quality_flags set status = 'resolved', resolution = ${"merged into " + intoId}, resolved_by = 'admin', resolved_at = now(), updated_at = now() where entity_type = 'project' and entity_id = ${dupId} and status = 'open'`);74 await tx`update projects set merged_into = ${intoId}, updated_at = now() where id = ${dupId}`;75 await tx`update projects set last_update = now(), updated_at = now() where id = ${intoId}`;76 await tx`insert into events (id, entity_type, entity_id, event_type, detected_at, old_value, new_value, source_id, url, title, summary, significance, confidence, review_status, country_iso2, operator_id, project_id, fingerprint)77 values (${newId("event")}, 'project', ${intoId}, 'facility_updated', now(), ${JSON.stringify({ mergedProject: dupId, name: dup.name })}::jsonb, ${JSON.stringify({ into: intoId })}::jsonb, 'src_manual', ${ADMIN_URL(intoId)}, ${"Duplicate project merged: " + String(dup.name)}, 'Merged by admin', 20, 'high', 'approved', ${str(into.country_iso2)}, ${str(into.operator_id)}, ${intoId}, ${"merge:project:" + dupId + ":" + intoId})78 on conflict (fingerprint) do nothing`;79 return { into: intoId, merged: dupId, moved };80 });81}8283/** snake_case column → provenance field name */84const COLUMN_TO_FIELD: Record<string, string> = { planned_mw: "plannedMw", investment_usd: "investmentUsd", expected_opening: "expectedOpening", announced_on: "announcedOn", construction_started_on: "constructionStartedOn", approved_on: "approvedOn", permit_filed_on: "permitFiledOn", opened_on: "openedOn", operator_id: "operatorId", country_iso2: "countryIso2", metro_id: "metroId", geo_precision: "geoPrecision", project_class: "projectClass", ai_evidence: "aiEvidence", is_ai: "isAi" };8586export interface PatchChange { field: string; column: string; oldValue: unknown; newValue: unknown }8788export async function patchProject(idOrSlug: string, fields: Record<string, unknown>, note: string | null): Promise<{ id: string; changed: PatchChange[] } | null> {89 const sql = pg();90 const cur = await findProject(idOrSlug);91 if (!cur) return null;92 const id = String(cur.id);93 const changed: PatchChange[] = [];94 const set: Record<string, unknown> = {};95 for (const [col, val] of Object.entries(fields)) {96 if (val === undefined) continue;97 const before = cur[col] ?? null;98 if (JSON.stringify(before) === JSON.stringify(val)) continue;99 set[col] = val;100 changed.push({ field: COLUMN_TO_FIELD[col] ?? col, column: col, oldValue: before, newValue: val });101 }102 if (!changed.length) return { id, changed };103 await ensureManualSource();104 const now = new Date().toISOString();105 if (set.name) set.normalized_name = normalizeName(String(set.name));106 if (set.planned_mw !== undefined) { set.capacity_scope = "facility"; set.capacity_semantics = "planned_power_mw"; }107 if (set.investment_usd !== undefined) { set.investment_scope = "facility"; set.investment_semantics = "project_investment_usd"; }108 if (set.lat !== undefined && set.lat !== null && set.geo_precision === undefined) set.geo_precision = "approximate";109 set.updated_at = now;110 set.last_update = now;111 set.confidence = "high";112 await sql.begin(async (tx) => {113 await tx`update projects set ${tx(set)} where id = ${id}`;114 for (const c of changed) await manualProvenance(tx, id, c.field, c.newValue, now, note, /mw$|investment/i.test(c.column) ? "facility" : null);115 const statusChange = changed.find((c) => c.column === "status");116 const mwChange = changed.find((c) => c.column === "planned_mw");117 const evt = statusChange ? { type: "project_status_changed", old: statusChange.oldValue, nw: statusChange.newValue, sig: 85 } : mwChange ? { type: "planned_capacity_changed", old: mwChange.oldValue, nw: mwChange.newValue, sig: 75 } : null;118 if (evt) {119 const digest = createHash("sha256").update(JSON.stringify([evt.type, evt.nw])).digest("hex").slice(0, 16);120 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, project_id, fingerprint)121 values (${newId("event")}, 'project', ${id}, ${evt.type}, ${now}, ${now.slice(0, 10)}, ${JSON.stringify(evt.old)}::jsonb, ${JSON.stringify(evt.nw)}::jsonb, 'src_manual', null, ${"https://www.datacenterindex.io/projects/" + String(cur.slug)}, ${`${String(set.name ?? cur.name)}: ${statusChange ? "status" : "planned capacity"} updated by curation`}, ${note}, ${evt.sig}, 'high', 'approved', ${(set.country_iso2 as string | undefined) ?? str(cur.country_iso2)}, ${(set.operator_id as string | undefined) ?? str(cur.operator_id)}, ${id}, ${"manual:project:" + id + ":" + now.slice(0, 10) + ":" + digest})122 on conflict (fingerprint) do nothing`;123 }124 });125 return { id, changed };126}127