/** Projects, events, news. */ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { publicGet } from "../../lib/route.js"; import { TTL, csv, notFound } from "../../lib/http.js"; import { boolParam, csvParam, intParam, numParam, orderParam, pageParam, strParam } from "../../lib/params.js"; import { sourcesForEntities, sourcesForIds } from "../../lib/source-history.js"; import { getProjectClaims, getProjectDetail, getProjectHistory, listProjects, projectPipeline } from "../../repositories/projects.js"; import { EVENTS_DEDUPE_METHODOLOGY, getEvent, listEvents } from "../../repositories/events.js"; import { listNews } from "../../repositories/misc.js"; const empty = (v: unknown) => (v === "" || v === null ? undefined : v); const 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)."; export async function activityRoutes(app: FastifyInstance): Promise { // ---- projects (static /pipeline before /:slug — find-my-way prefers static anyway) 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 () => { const data = await projectPipeline(); 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}` } }; }); 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 }); 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) => { 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) }); 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)) }; }); 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) => { const res = await getProjectDetail(params.slug!, { radiusKm: q.radius_km != null ? Math.min(200, Math.max(0.1, q.radius_km)) : undefined }); if (!res) throw notFound("project"); 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.` } }; }); 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) => { const res = await getProjectHistory(params.slug!); if (!res) throw notFound("project"); return { data: res.history, sources: res.sources }; }); 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) => { const res = await getProjectClaims(params.slug!, { status: csv(q.status), predicate: q.predicate }); if (!res) throw notFound("project"); return { data: res.claims, sources: res.sources, meta: { total: res.claims.length, projectId: res.id } }; }); // ---- events const eventsQuery = z.object({ type: csvParam, country: strParam, metro: strParam, operator: strParam, project: strParam, entity_type: strParam, entity_id: strParam, min_significance: intParam, significance: z.preprocess(empty, z.enum(["major", "medium", "minor"]).optional()), confidence: csvParam, source_kind: csvParam, ai: boolParam, since: strParam, until: strParam, q: strParam, dedupe: boolParam, page: pageParam, per_page: intParam, }); 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) => { 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 }); 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)) }; }); 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) => { const e = await getEvent(params.id!); if (!e) throw notFound("event"); return { data: e, sources: await sourcesForIds([e.sourceId]) }; }); // ---- news 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) => { const res = await listNews(q); return { data: res.items, meta: { total: res.total, page: res.page, perPage: res.perPage } }; }); }