import type { FreeLabelGraph, FreeLabelDetail as ApiFreeLabelDetail, LabelsSummary, PooledTweet, ServiceAccountScope, ServiceAccountsSnapshot, ServiceAccountSummary, IssuedServiceAccountCredential, } from "@xtap-pool/shared"; import { communityGroups } from "./communities"; export type TweetRecord = { tweet: PooledTweet; contributors: readonly string[]; }; export type TweetPage = { records: readonly TweetRecord[]; nextCursor?: string; }; export type ContributorStats = { username: string; tweetCount: number; lastPooledAt: string; }; export type Me = { username: string; isAdmin: boolean; }; export type MemberOrgGrant = { name: string; sub: string; display_name?: string; }; export type PoolSnapshot = { version: 1; admins: readonly string[]; members: readonly string[]; member_orgs: readonly MemberOrgGrant[]; bootstrap_admins: readonly string[]; updated_at: string; updated_by?: string; source: "dataset" | "bootstrap"; config_error?: string; }; export type AdminPoolResponse = { pool: PoolSnapshot; viewer: { username: string }; }; export type AdminEnrichment = { contract_hash: string; totals: { pending: number; running: number; retrying: number; blocked: number; completed: number; }; worker_recently_completed: boolean; freshness_lag_seconds?: number; recent_errors: readonly { error_class: string; count: number }[]; }; export type AdminFreeLabels = { registry_revision: number; labels: readonly { name: string; status: "candidate" | "approved" | "rejected"; unit_count: number; reason?: string; }[]; candidates: readonly { name: string; unit_count: number; distinct_authors: number; distinct_days: number; representative_quotes: readonly { unit_id: string; tweet_id: string; quote: string }[]; }[]; }; /** * Enrichment API shapes (labels/free labels/graph). Local contract types for * the endpoints described in docs/labels-and-free-labels-implementation-plan.md. */ export type LabelStat = { name: string; description?: string; count: number; }; export type FreeLabelSummary = { name: string; post_count: number; }; export type RelatedFreeLabel = { name: string; shared: number; }; export type FreeLabelDetail = { name: string; related: readonly RelatedFreeLabel[]; post_count: number; }; export type FreeLabelGraphData = { nodes: readonly { id: string; name: string; docs: number; group: number }[]; links: readonly { source: string; target: string; weight: number }[]; }; /** Free label listing (approved names only). */ export type FreeLabelStat = { name: string; count: number }; export type Filters = { contributors: readonly string[]; labels: readonly string[]; freeLabel: string; q: string; since: string; until: string; hasMedia: boolean; isArticle: boolean; dedup: boolean; }; export const defaultFilters: Filters = { contributors: [], labels: [], freeLabel: "", q: "", since: "", until: "", hasMedia: false, isArticle: false, dedup: true, }; function nonEmpty(value: string): string | undefined { return value === "" ? undefined : value; } function flag(active: boolean): string | undefined { return active ? "true" : undefined; } /** Serialize UI filter state into /api/tweets query parameters. */ export function tweetsQueryString(filters: Filters, cursor?: string): string { const until = nonEmpty(filters.until); const entries: [string, string | undefined][] = [ ["contributors", nonEmpty(filters.contributors.join(","))], ["labels", nonEmpty(filters.labels.join(","))], ["free_label", nonEmpty(filters.freeLabel)], ["q", nonEmpty(filters.q)], ["since", nonEmpty(filters.since)], ["until", until === undefined ? undefined : `${until}T23:59:59.999Z`], ["has_media", flag(filters.hasMedia)], ["is_article", flag(filters.isArticle)], ["dedup", String(filters.dedup)], ["cursor", cursor], ]; const params = new URLSearchParams(); for (const [key, value] of entries) { if (value !== undefined) params.set(key, value); } return params.toString(); } async function getJson(path: string): Promise { const response = await fetch(path, { headers: { accept: "application/json" } }); if (response.status === 401) return undefined; if (!response.ok) throw new Error(`request failed: ${String(response.status)} ${path}`); return (await response.json()) as T; } async function sendJson( path: string, method: "POST" | "PUT" | "DELETE", body?: unknown, ): Promise { const response = await fetch(path, { method, headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (!response.ok) throw new Error(`request failed: ${String(response.status)} ${path}`); return (await response.json()) as T; } /** Current viewer, or undefined when not signed in. */ export async function fetchMe(): Promise { const me = await getJson & { username: string }>("/api/me"); return me === undefined ? undefined : { username: me.username, isAdmin: me.isAdmin === true }; } export async function fetchTweets(filters: Filters, cursor?: string): Promise { const page = await getJson(`/api/tweets?${tweetsQueryString(filters, cursor)}`); if (page === undefined) throw new Error("session expired"); return page; } export async function fetchContributors(): Promise { const body = await getJson<{ contributors: ContributorStats[] }>("/api/contributors"); if (body === undefined) throw new Error("session expired"); return body.contributors; } /** Preset label taxonomy with counts. */ export async function fetchLabels(): Promise { const body = await getJson("/api/labels"); if (body === undefined) throw new Error("session expired"); return body.labels.map(({ name, description, count }) => ({ name, description, count, })); } /** * Approved free-label vocabulary with unit counts. */ export async function fetchFreeLabels(): Promise { const body = await getJson<{ free_labels: FreeLabelStat[] }>("/api/free-labels"); if (body === undefined) throw new Error("session expired"); return body.free_labels.map((entry) => ({ name: entry.name, post_count: entry.count, })); } /** One approved free label with related approved labels. */ export async function fetchFreeLabel(name: string): Promise { const body = await getJson(`/api/free-labels/${encodeURIComponent(name)}`); if (body === undefined) throw new Error("session expired"); return { name: body.name, related: body.related.map((entry) => ({ name: entry.name, shared: entry.shared_units, })), post_count: body.tweet_count, }; } /** Bounded co-occurrence subgraph of the top approved free labels. */ export async function fetchFreeLabelGraph(top: number): Promise { const body = await getJson(`/api/graph?top=${String(top)}`); if (body === undefined) throw new Error("session expired"); const groups = communityGroups( body.nodes.map((node) => node.name), body.links, ); return { nodes: body.nodes.map((node) => ({ id: node.name, name: node.name, docs: node.unit_count, group: groups[node.name] ?? -1, })), links: body.links, }; } export async function fetchAdminPool(): Promise { const body = await getJson("/api/admin/pool"); if (body === undefined) throw new Error("session expired"); return body; } export async function fetchAdminEnrichment(): Promise { const body = await getJson("/api/admin/enrichment"); if (body === undefined) throw new Error("session expired"); return body; } export async function fetchAdminFreeLabels(): Promise { const body = await getJson("/api/admin/free-labels"); if (body === undefined) throw new Error("session expired"); return body; } export async function repairPoolConfig(): Promise { return (await sendJson<{ pool: PoolSnapshot }>("/api/admin/pool/repair", "POST")).pool; } export async function fetchAdminServiceAccounts(): Promise { const body = await getJson<{ service_accounts: ServiceAccountsSnapshot }>( "/api/admin/service-accounts", ); if (body === undefined) throw new Error("session expired"); return body.service_accounts; } export async function issueServiceAccount( name: string, scopes: readonly ServiceAccountScope[], ): Promise { return sendJson("/api/admin/service-accounts", "POST", { name, scopes, }); } export async function rotateServiceAccount( accountId: string, ): Promise { return sendJson( `/api/admin/service-accounts/${encodeURIComponent(accountId)}/keys`, "POST", ); } export async function revokeServiceAccount(accountId: string): Promise { return ( await sendJson<{ account: ServiceAccountSummary }>( `/api/admin/service-accounts/${encodeURIComponent(accountId)}`, "DELETE", ) ).account; } export async function revokeServiceAccountKey( accountId: string, keyId: string, ): Promise { return ( await sendJson<{ account: ServiceAccountSummary }>( `/api/admin/service-accounts/${encodeURIComponent(accountId)}/keys/${encodeURIComponent(keyId)}`, "DELETE", ) ).account; } export async function repairServiceAccounts(): Promise { return ( await sendJson<{ service_accounts: ServiceAccountsSnapshot }>( "/api/admin/service-accounts/repair", "POST", ) ).service_accounts; } export async function addPoolMember(username: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/members/${encodeURIComponent(username)}`, "PUT", ) ).pool; } export async function removePoolMember(username: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/members/${encodeURIComponent(username)}`, "DELETE", ) ).pool; } export async function addPoolAdmin(username: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/admins/${encodeURIComponent(username)}`, "PUT", ) ).pool; } export async function removePoolAdmin(username: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/admins/${encodeURIComponent(username)}`, "DELETE", ) ).pool; } export async function addPoolMemberOrg(orgName: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/member-orgs/${encodeURIComponent(orgName)}`, "PUT", ) ).pool; } export async function removePoolMemberOrg(orgName: string): Promise { return ( await sendJson<{ pool: PoolSnapshot }>( `/api/admin/member-orgs/${encodeURIComponent(orgName)}`, "DELETE", ) ).pool; }