spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * Private watchlists — no accounts. The owner is an opaque random token stored in the httpOnly `dci_watch` cookie;3 * rows are only ever read / deleted through that token.4 */5import { randomBytes } from "node:crypto";6import type { EventDTO, WatchlistItem } from "@dci/core";7import { newId } from "@dci/core";8import { pg, eventCols, eventJoins, page } from "../lib/sql.js";9import { reqIso, reqStr, str, type Row } from "../lib/rows.js";10import { findBySlugOrId, findCountry, resolveEntityRefs } from "../lib/resolve.js";11import { toEventDtos } from "./events.js";1213export const WATCH_COOKIE = "dci_watch";14export const WATCH_MAX_ITEMS = 200;15export const WATCH_ENTITY_TYPES = ["operator", "metro", "country", "project", "facility"] as const;16export type WatchEntityType = (typeof WATCH_ENTITY_TYPES)[number];1718export function newWatchToken(): string {19 return randomBytes(24).toString("base64url");20}2122export function parseCookies(header: string | undefined): Record<string, string> {23 const out: Record<string, string> = {};24 if (!header) return out;25 for (const part of header.split(";")) {26 const i = part.indexOf("=");27 if (i < 0) continue;28 const k = part.slice(0, i).trim();29 const v = part.slice(i + 1).trim();30 if (k && /^[A-Za-z0-9_-]{8,128}$/.test(v)) out[k] = v;31 }32 return out;33}3435const TABLE: Record<WatchEntityType, "operators" | "metros" | "projects" | "facilities" | null> = { operator: "operators", metro: "metros", project: "projects", facility: "facilities", country: null };3637/** Resolve a slug or id to the canonical entity id (null when unknown). */38export async function resolveWatchEntity(type: WatchEntityType, idOrSlug: string): Promise<string | null> {39 if (type === "country") { const c = await findCountry(idOrSlug); return c ? reqStr(c.iso2) : null; }40 const row = await findBySlugOrId(TABLE[type]!, idOrSlug);41 if (!row) return null;42 if (type === "project" && (row.hidden === true || row.merged_into)) return str(row.merged_into) ?? null;43 return reqStr(row.id);44}4546async function toItems(rows: Row[]): Promise<WatchlistItem[]> {47 const refs = await resolveEntityRefs(rows.map((r) => ({ type: reqStr(r.entity_type), id: reqStr(r.entity_id) })));48 return rows.map((r) => {49 const ref = refs.get(`${reqStr(r.entity_type)}:${reqStr(r.entity_id)}`);50 return { id: reqStr(r.id), entityType: reqStr(r.entity_type) as WatchEntityType, entityId: reqStr(r.entity_id), slug: ref?.slug ?? reqStr(r.entity_id), name: ref?.name ?? reqStr(r.entity_id), createdAt: reqIso(r.created_at) };51 });52}5354export async function listWatchlist(token: string): Promise<WatchlistItem[]> {55 const sql = pg();56 const rows = await sql<Row[]>`select * from watchlists where owner_token = ${token} order by created_at desc limit ${WATCH_MAX_ITEMS}`;57 return toItems(rows);58}5960export async function addWatch(token: string, type: WatchEntityType, entityId: string): Promise<{ item: WatchlistItem; created: boolean } | { error: "full" }> {61 const sql = pg();62 const n = await sql<Row[]>`select count(*)::int as n from watchlists where owner_token = ${token}`;63 const existing = await sql<Row[]>`select * from watchlists where owner_token = ${token} and entity_type = ${type} and entity_id = ${entityId}`;64 if (existing[0]) return { item: (await toItems(existing))[0]!, created: false };65 if (Number(n[0]?.n ?? 0) >= WATCH_MAX_ITEMS) return { error: "full" };66 const rows = await sql<Row[]>`insert into watchlists (id, owner_token, entity_type, entity_id) values (${newId("watch")}, ${token}, ${type}, ${entityId}) on conflict (owner_token, entity_type, entity_id) do update set entity_id = excluded.entity_id returning *`;67 return { item: (await toItems(rows))[0]!, created: true };68}6970export async function removeWatch(token: string, id: string): Promise<boolean> {71 const sql = pg();72 const rows = await sql`delete from watchlists where owner_token = ${token} and id = ${id} returning id`;73 return rows.length > 0;74}7576/** Events for the watched entities, newest first, one row per cluster. */77export async function watchFeed(token: string, pageNo?: number, perPage?: number): Promise<{ items: EventDTO[]; total: number; page: number; perPage: number }> {78 const sql = pg();79 const pg_ = page(pageNo, perPage, 100, 50);80 const rows = await sql<Row[]>`81 with w as (select entity_type, entity_id from watchlists where owner_token = ${token}),82 matched as (83 select distinct on (coalesce(e.cluster_id, e.id)) e.id84 from events e85 where e.review_status <> 'rejected' and (86 exists (select 1 from w where w.entity_type = 'operator' and (e.operator_id = w.entity_id or (e.entity_type = 'operator' and e.entity_id = w.entity_id)))87 or exists (select 1 from w where w.entity_type = 'country' and e.country_iso2 = w.entity_id)88 or exists (select 1 from w where w.entity_type = 'project' and (e.project_id = w.entity_id or (e.entity_type = 'project' and e.entity_id = w.entity_id)))89 or exists (select 1 from w where w.entity_type = 'facility' and e.entity_type = 'facility' and e.entity_id = w.entity_id)90 or exists (select 1 from w where w.entity_type = 'metro' and (e.metro_id = w.entity_id or (e.entity_type = 'facility' and e.entity_id in (select id from facilities where metro_id = w.entity_id))))91 )92 order by coalesce(e.cluster_id, e.id), e.significance desc, e.detected_at asc93 )94 select ${eventCols(sql)}, count(*) over() as total from events e ${eventJoins(sql)}95 where e.id in (select id from matched)96 order by e.detected_at desc, e.id desc limit ${pg_.perPage} offset ${pg_.offset}`;97 const total = rows.length ? Number(rows[0]!.total) : 0;98 return { items: await toEventDtos(rows), total, page: pg_.page, perPage: pg_.perPage };99}100