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%
8.0 KB · 110 lines typescript
Raw Blame History
1/** Project curation routes: hide / unhide / merge / PATCH. */2import type { FastifyInstance } from "fastify";3import { z } from "zod";4import { AI_EVIDENCE_LEVELS, FACILITY_STATUSES, GEO_PRECISIONS, PROJECT_CLASSES, parsePartialDate } from "@dci/core";5import { envelope, notFound, parseBody, badRequest } from "../../lib/http.js";6import { pg } from "../../lib/sql.js";7import { invalidate } from "../../cache.js";8import { mergeProject, patchProject, setProjectHidden } from "../../repositories/admin/projects.js";910const partialDate = z.string().max(20).nullable().refine((v) => v == null || parsePartialDate(v) != null, "not a (partial) date");1112const patchSchema = z.object({13  name: z.string().min(2).max(200).optional(),14  status: z.enum(FACILITY_STATUSES).optional(),15  planned_mw: z.number().positive().max(20000).nullable().optional(),16  investment_usd: z.number().positive().nullable().optional(),17  expected_opening: partialDate.optional(),18  announced_on: partialDate.optional(),19  construction_started_on: partialDate.optional(),20  approved_on: partialDate.optional(),21  permit_filed_on: partialDate.optional(),22  opened_on: partialDate.optional(),23  operator_id: z.string().nullable().optional(),24  country_iso2: z.string().length(2).nullable().optional(),25  metro_id: z.string().nullable().optional(),26  city: z.string().max(200).nullable().optional(),27  lat: z.number().min(-90).max(90).nullable().optional(),28  lng: z.number().min(-180).max(180).nullable().optional(),29  geo_precision: z.enum(GEO_PRECISIONS).optional(),30  project_class: z.enum(PROJECT_CLASSES).nullable().optional(),31  ai_evidence: z.enum(AI_EVIDENCE_LEVELS).optional(),32  is_ai: z.boolean().optional(),33  description: z.string().max(5000).nullable().optional(),34  note: z.string().max(500).optional(),35}).strict();3637const CACHE_PREFIXES = ["/projects", "/dashboard", "/map", "/explore", "/pulse"];38async function bust(): Promise<void> { await Promise.all(CACHE_PREFIXES.map((p) => invalidate(p))); }3940const listSchema = z.object({ hidden: z.enum(["true", "false", "all"]).optional(), q: z.string().max(200).optional(), min_priority: z.coerce.number().int().min(0).max(100).optional(), page: z.coerce.number().int().min(1).optional(), per_page: z.coerce.number().int().min(1).max(200).optional() }).strict();4142export async function projectAdminRoutes(app: FastifyInstance): Promise<void> {43  app.get("/projects", { schema: { summary: "Admin project list: hidden=true|false|all (default all), q, min_priority, page, per_page — includes hidden / merged rows, review priority, class, evidence, open flags" } }, async (req) => {44    const q = listSchema.parse(req.query ?? {});45    const sql = pg();46    const perPage = q.per_page ?? 50, page = q.page ?? 1;47    const like = q.q ? `%${q.q.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null;48    const hiddenCond = q.hidden === "true" ? sql`and p.hidden` : q.hidden === "false" ? sql`and not p.hidden and p.merged_into is null` : sql``;49    const rows = await sql`50      select p.id, p.slug, p.name, p.status, p.planned_mw, p.investment_usd, p.country_iso2, p.city, p.project_class, p.evidence_level, p.ai_evidence, p.capacity_scope, p.investment_scope, p.hidden, p.merged_into, p.review_priority, p.source_url, p.announced_on, p.updated_at,51        o.name as operator_name, o.slug as operator_slug,52        (select count(*)::int from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open') as open_flags,53        (select json_agg(json_build_object('code', f.code, 'severity', f.severity, 'message', f.message)) from (select code, severity, message from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open' order by priority desc limit 5) f) as flags,54        count(*) over() as total55      from projects p left join operators o on o.id = p.operator_id56      where true ${hiddenCond} ${like ? sql`and (p.name ilike ${like} or p.slug ilike ${like})` : sql``} ${q.min_priority != null ? sql`and p.review_priority >= ${q.min_priority}` : sql``}57      order by p.review_priority desc, p.updated_at desc limit ${perPage} offset ${(page - 1) * perPage}`;58    const total = rows.length ? Number(rows[0]!.total) : 0;59    return envelope(rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, status: r.status, plannedMw: r.planned_mw == null ? null : Number(r.planned_mw), investmentUsd: r.investment_usd == null ? null : Number(r.investment_usd), countryIso2: r.country_iso2, city: r.city, projectClass: r.project_class, evidenceLevel: r.evidence_level, aiEvidence: r.ai_evidence, capacityScope: r.capacity_scope, investmentScope: r.investment_scope, hidden: Boolean(r.hidden), mergedInto: r.merged_into, reviewPriority: Number(r.review_priority ?? 0), sourceUrl: r.source_url, announcedOn: r.announced_on, updatedAt: r.updated_at, operator: r.operator_name ? { name: r.operator_name, slug: r.operator_slug } : null, openFlags: Number(r.open_flags ?? 0), flags: r.flags ?? [] })), { total, page, perPage });60  });6162  app.post("/projects/:id/hide", { schema: { summary: "Hide a project (false positive kept for audit, never listed) {reason}; writes src_manual provenance + a project_status_changed event {hidden}" } }, async (req) => {63    const { id } = req.params as { id: string };64    const body = parseBody(z.object({ reason: z.string().min(1).max(500) }), req.body);65    const res = await setProjectHidden(id, true, body.reason);66    if (!res) throw notFound("project");67    await bust();68    return envelope(res);69  });7071  app.post("/projects/:id/unhide", { schema: { summary: "Restore a hidden project {reason?}" } }, async (req) => {72    const { id } = req.params as { id: string };73    const body = parseBody(z.object({ reason: z.string().max(500).optional() }), req.body ?? {});74    const res = await setProjectHidden(id, false, body.reason ?? null);75    if (!res) throw notFound("project");76    await bust();77    return envelope(res);78  });7980  app.post("/projects/:id/merge", { schema: { summary: "Merge this project into another {into}: events, timeline, claims, provenance, entity keys move to the survivor; the duplicate keeps merged_into" } }, async (req) => {81    const { id } = req.params as { id: string };82    const body = parseBody(z.object({ into: z.string().min(1) }), req.body);83    let res: Awaited<ReturnType<typeof mergeProject>>;84    try { res = await mergeProject(id, body.into); } catch (e) {85      const msg = (e as Error).message;86      if (msg === "project not found") throw notFound("project");87      throw badRequest(msg);88    }89    await bust();90    return envelope(res);91  });9293  app.patch("/projects/:id", { schema: { summary: "Curate project fields (strict body); src_manual provenance on every field; events only for status (project_status_changed) and planned_mw (planned_capacity_changed)" } }, async (req) => {94    const { id } = req.params as { id: string };95    const body = parseBody(patchSchema, req.body);96    const { note, ...fields } = body;97    const entries = Object.entries(fields).filter(([, v]) => v !== undefined);98    if (!entries.length) throw badRequest("no fields to update");99    if ((fields.lat === undefined) !== (fields.lng === undefined) || (fields.lat == null) !== (fields.lng == null)) throw badRequest("lat and lng must be set together");100    const sql = pg();101    if (fields.operator_id) { const r = await sql`select id from operators where id = ${fields.operator_id}`; if (!r.length) throw badRequest("operator_id does not exist"); }102    if (fields.country_iso2) { fields.country_iso2 = fields.country_iso2.toUpperCase(); const r = await sql`select iso2 from countries where iso2 = ${fields.country_iso2}`; if (!r.length) throw badRequest("country_iso2 does not exist"); }103    if (fields.metro_id) { const r = await sql`select id from metros where id = ${fields.metro_id}`; if (!r.length) throw badRequest("metro_id does not exist"); }104    const res = await patchProject(id, fields, note ?? null);105    if (!res) throw notFound("project");106    if (res.changed.length) await bust();107    return envelope({ ...res, message: res.changed.length ? undefined : "no changes" });108  });109}110