SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.6 KB · 100 lines typescript
Raw Blame History
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