spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/** Projects, events, news. */2import type { FastifyInstance } from "fastify";3import { z } from "zod";4import { publicGet } from "../../lib/route.js";5import { TTL, csv, notFound } from "../../lib/http.js";6import { boolParam, csvParam, intParam, numParam, orderParam, pageParam, strParam } from "../../lib/params.js";7import { sourcesForEntities, sourcesForIds } from "../../lib/source-history.js";8import { getProjectClaims, getProjectDetail, getProjectHistory, listProjects, projectPipeline } from "../../repositories/projects.js";9import { EVENTS_DEDUPE_METHODOLOGY, getEvent, listEvents } from "../../repositories/events.js";10import { listNews } from "../../repositories/misc.js";1112const empty = (v: unknown) => (v === "" || v === null ? undefined : v);13const PROJECTS_NOTE = "Project records only (never facilities). False positives hidden by review and merged duplicates are excluded from every list, count and sum. plannedMw / investmentUsd are site-scoped published figures; company-wide or portfolio totals stay as claims (see /projects/:slug/claims).";1415export async function activityRoutes(app: FastifyInstance): Promise<void> {16 // ---- projects (static /pipeline before /:slug — find-my-way prefers static anyway)17 publicGet(app, { url: "/projects/pipeline", ttl: TTL.list, summary: "Project pipeline aggregates: by status, by expected year, top 15 countries", tags: ["projects"], response: { type: "PipelineAggregates" } }, async () => {18 const data = await projectPipeline();19 return { data, meta: { methodology: `count and SUM(planned_mw) over live projects (hidden / merged excluded); byYear uses the year prefix of expected_opening and excludes cancelled/closed. ${PROJECTS_NOTE}` } };20 });2122 const projectsQuery = z.object({ status: csvParam, project_status: csvParam, country: strParam, operator: strParam, metro: strParam, min_mw: numParam, max_mw: numParam, ai: boolParam, project_class: csvParam, evidence_level: csvParam, expected_from: intParam, expected_to: intParam, announced_since: strParam, q: strParam, sort: z.preprocess(empty, z.enum(["updated", "mw", "announced", "opening"]).optional()), order: orderParam, page: pageParam, per_page: intParam });23 publicGet(app, { url: "/projects", ttl: TTL.list, query: projectsQuery, summary: "List projects (ProjectSummary[]) — status/project_status, country, operator, metro, min/max_mw, ai, project_class, evidence_level, expected_from/to, announced_since, q", tags: ["projects"], response: { type: "ProjectSummary[]" } }, async (q) => {24 const res = await listProjects({ ...q, status: [...csv(q.status), ...csv(q.project_status)], project_class: csv(q.project_class), evidence_level: csv(q.evidence_level) });25 return { data: res.items, meta: { total: res.total, page: res.page, perPage: res.perPage, methodology: PROJECTS_NOTE }, sources: await sourcesForEntities("project", res.items.map((p) => p.id)) };26 });27 publicGet(app, { url: "/projects/:slug", ttl: TTL.detail, query: z.object({ radius_km: numParam }), summary: "Project detail (ProjectDetail): lifecycle stages + velocity, claims, history, nearby infrastructure (?radius_km), related events, data quality, provenance, sourceHistory", tags: ["projects"], params: { slug: "project slug or prj_… id" }, response: { type: "ProjectDetail" } }, async (q, params) => {28 const res = await getProjectDetail(params.slug!, { radiusKm: q.radius_km != null ? Math.min(200, Math.max(0.1, q.radius_km)) : undefined });29 if (!res) throw notFound("project");30 return { data: res.detail, sources: res.sources, meta: { methodology: `${PROJECTS_NOTE} Stages are dated from the project's dated columns, its timeline and status-change events (earliest evidence wins); velocityDays are day counts between dated stages, null when a date is unknown.` } };31 });32 publicGet(app, { url: "/projects/:slug/history", ttl: TTL.detail, summary: "Project field history (EntityHistory)", tags: ["projects"], params: { slug: "project slug or prj_… id" }, response: { type: "EntityHistory" } }, async (_q, params) => {33 const res = await getProjectHistory(params.slug!);34 if (!res) throw notFound("project");35 return { data: res.history, sources: res.sources };36 });37 publicGet(app, { url: "/projects/:slug/claims", ttl: TTL.detail, query: z.object({ status: csvParam, predicate: strParam }), summary: "Claims about a project (ClaimDTO[]) — capacity and investment figures with scope and evidence", tags: ["projects"], params: { slug: "project slug or prj_… id" }, response: { type: "ClaimDTO[]" } }, async (q, params) => {38 const res = await getProjectClaims(params.slug!, { status: csv(q.status), predicate: q.predicate });39 if (!res) throw notFound("project");40 return { data: res.claims, sources: res.sources, meta: { total: res.claims.length, projectId: res.id } };41 });4243 // ---- events44 const eventsQuery = z.object({45 type: csvParam, country: strParam, metro: strParam, operator: strParam, project: strParam, entity_type: strParam, entity_id: strParam,46 min_significance: intParam, significance: z.preprocess(empty, z.enum(["major", "medium", "minor"]).optional()), confidence: csvParam, source_kind: csvParam, ai: boolParam,47 since: strParam, until: strParam, q: strParam, dedupe: boolParam, page: pageParam, per_page: intParam,48 });49 publicGet(app, { url: "/events", ttl: TTL.events, query: eventsQuery, summary: "Change feed (EventDTO[]) — newest first; filters: type, country, metro, operator, project, entity, significance band, confidence, source_kind, ai, since/until, q; dedupe=true collapses clustered coverage", tags: ["events"], response: { type: "EventDTO[]" } }, async (q) => {50 const res = await listEvents({ type: csv(q.type), country: q.country, operator: q.operator, metro: q.metro, project: q.project, entityType: q.entity_type, entityId: q.entity_id, minSignificance: q.min_significance, significance: q.significance, confidence: csv(q.confidence), sourceKind: csv(q.source_kind), ai: q.ai, since: q.since, until: q.until, q: q.q, dedupe: q.dedupe !== false, page: q.page, perPage: q.per_page });51 return { data: res.items, meta: { total: res.total, page: res.page, perPage: res.perPage, dedupe: q.dedupe !== false, methodology: EVENTS_DEDUPE_METHODOLOGY }, sources: await sourcesForIds(res.items.map((e) => e.sourceId)) };52 });53 publicGet(app, { url: "/events/:id", ttl: TTL.detail, summary: "Single event (EventDTO) with otherSources", tags: ["events"], params: { id: "evt_… id" }, response: { type: "EventDTO" } }, async (_q, params) => {54 const e = await getEvent(params.id!);55 if (!e) throw notFound("event");56 return { data: e, sources: await sourcesForIds([e.sourceId]) };57 });5859 // ---- news60 publicGet(app, { url: "/news", ttl: TTL.events, query: z.object({ country: strParam, operator: strParam, since: strParam, q: strParam, page: pageParam, per_page: intParam }), summary: "News items (crawled announcements) — newest first", tags: ["events"], response: { type: "NewsItemDTO[]" } }, async (q) => {61 const res = await listNews(q);62 return { data: res.items, meta: { total: res.total, page: res.page, perPage: res.perPage } };63 });64}65