// The admin surface: discovery purge, system configuration, users & roles, the audit log, the // diagnostics console's server log, the scheduler + maintenance controls, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one // module as the cohesive "admin" domain.) import type { Level } from "../log"; import { req } from "./core"; export interface Invite { id: number; email: string; status: "pending" | "approved" | "denied"; note: string | null; requested_at: string | null; decided_at: string | null; decided_by: string | null; /** An account already exists for this address, so the entry can't be deleted from here — deleting * it would revoke that user's access (is_allowed reads this row). Delete the user instead. */ has_account: boolean; /** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing * this row would NOT take access away. The delete confirmation says so. */ still_allowed_by_env: boolean; /** When the admin last invited this person, or null if never. Adding an address to the whitelist * is deliberately silent — it opens a door rather than knocking — so telling them is a separate * act, and this is how the admin sees they have already done it. */ invited_at: string | null; /** The language this person asked us in, or null when nobody ever told us — a row the admin * whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI * language, so the guess is visible and correctable before anything is sent. */ lang: string | null; } /** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the * one thing the client cannot work out for itself. */ export interface PendingAccessRow extends AdminUserRow { /** The address is already allowed and the account is merely inactive, so Activate finishes a * decision the admin has already made. False when there is no approved invite behind it — * activating then would make an account that signs in with a password but is turned away by * Google, so the whitelist box is the honest move instead. */ can_activate: boolean; } // Admin Configuration page: one DB-overridable setting (registry entry on the backend). export interface ConfigItem { key: string; type: "int" | "str" | "bool" | "choice"; group: string; secret: boolean; min: number | null; max: number | null; is_set: boolean; // a DB override row exists (else using the env/config default) allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting choices: string[]; // a "choice" key's options (the backend Literal); [] for any other type value?: string | number | boolean; // non-secret: effective value default?: string | number | boolean; // non-secret: env/config default default_is_set?: boolean; // secret: whether an env default exists } export interface SystemConfigData { groups: Record; /** The page's headings, in order, each with its groups in order; every group is in one. */ sections: { id: string; groups: string[] }[]; secrets_manageable: boolean; } // Admin Users & roles page. export interface AdminUserRow { id: number; email: string; display_name: string | null; role: string; // "user" | "admin" is_active: boolean; is_suspended: boolean; email_verified: boolean; has_password: boolean; /** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only * account) from "we took it away" — different problems needing different help. */ password_revoked_at: string | null; has_google: boolean; /** The Google account's address, ONLY when it differs from the account address. */ google_email: string | null; created_at: string | null; /** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */ last_login_at: string | null; } /** The account panel's view of why someone can or cannot get in. */ export interface AdminUserAccess extends AdminUserRow { /** The address passes the invite gate — so Google sign-in will be let through, not filed as a * fresh access request. */ is_allowed: boolean; can_activate: boolean; /** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery * actions are offered or shown as unavailable — rather than failing after the click. */ email_configured: boolean; } export interface AuditRow { id: number; created_at: string | null; /** The acting admin (address in the page's `accounts`); null = the background. */ actor_id: number | null; action: string; // canonical key, localized on the client via auditActions. target_type: string | null; target_id: string | null; summary: string | null; before: Record | null; after: Record | null; /** The request the change was made in — its log lines are one pivot away. Null on older rows. */ request_id: string | null; /** How many of that request's log lines are still kept (they go after days, the row after months). */ log_lines: number; } /** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */ export interface HistoryPage { items: T[]; /** The accounts this page names, by id; an erased one is absent. */ accounts: Record; next_cursor: string | null; } /** The filters every activity tab shares, as the APIs take them. */ export interface SharedQuery { /** An account id, or `none` for what no account did. */ user?: string; q?: string; since?: string; } export interface AuditQuery extends SharedQuery { action?: string; } /** One background run (`job_runs`), as the activity page lists it. */ export interface RunRow { id: number; job: string; trigger: RunBrief["trigger"]; actor_id: number | null; /** What a user's ad-hoc run worked on (a channel id), and that channel's title. */ target: string | null; target_title: string | null; status: RunBrief["status"]; started_at: string; finished_at: string | null; summary: string | null; result: JobResult | null; error: string | null; /** A failure the job named (`progress.JobFailed`): its reason, and the values it is said with. */ failure: { failure: string; [key: string]: unknown } | null; quota_units: number; /** How many stored log lines the run left (WARNING and up only). */ log_lines: number; } export interface RunQuery extends SharedQuery { job?: string; status?: string; } /** The job menu and the status chips, each counted under every filter but its own. */ export interface RunFacets { jobs: LogFacet[]; statuses: LogFacet[]; total: number; } /** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's * redactor took out arrive as `[redacted]` / `[email]`. */ export interface ServerLogRow { id: number; ts: string; level: Level; source: string; logger: string; event: string | null; msg: string; attrs: Record | null; exc: string | null; request_id: string | null; user_id: number | null; job_id: number | null; run_id: number | null; debug_session_id: string | null; version: string | null; } /** The console's filters, as the API takes them. `none` picks the rows without an account / code. */ export interface LogQuery { levels?: readonly Level[]; sources?: readonly string[]; user?: string; event?: string; request_id?: string; job_id?: number; run_id?: number; q?: string; since?: string; } export interface LogPage { items: ServerLogRow[]; /** The accounts this page names, by id; an erased one is absent. */ accounts: Record; next_cursor: string | null; } export interface LogFacet { value: string; count: number; } /** Each facet is counted under every filter but its own; `total` is the all-levels count. */ export interface LogFacets { levels: LogFacet[]; sources: LogFacet[]; events: LogFacet[]; users: LogFacet[]; accounts: Record; total: number; } function logParams(query: LogQuery, extra: Record = {}): string { const p = new URLSearchParams(); for (const level of query.levels ?? []) p.append("levels", level); for (const source of query.sources ?? []) p.append("sources", source); const single: Record = { user: query.user, event: query.event, request_id: query.request_id, job_id: query.job_id, run_id: query.run_id, q: query.q, since: query.since, ...extra, }; for (const [key, value] of Object.entries(single)) { if (value !== undefined && value !== null && value !== "") p.set(key, String(value)); } const s = p.toString(); return s ? `?${s}` : ""; } /** `?a=1&b=x` from the set values (an empty string or null is left out), or "" when none is. */ function queryString(values: object): string { const p = new URLSearchParams(); for (const [key, value] of Object.entries(values) as [string, unknown][]) { if (value !== undefined && value !== null && value !== "") p.set(key, String(value)); } const s = p.toString(); return s ? `?${s}` : ""; } export type JobResult = { [key: string]: number | string | JobResult }; /** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`: * the process stopped mid-run (a deploy, a crash), and its next start closed the row. The values are * held to the server's by backendIds.contract.test (through RUN_STATUSES, typed as `RunStatus`). */ export type RunOutcome = "ok" | "error" | "skipped" | "interrupted"; export type RunStatus = RunOutcome | "running"; /** One past run, as the row's history dots show it. */ export interface RunBrief { id: number; status: RunOutcome | "running"; trigger: "scheduled" | "manual" | "ad_hoc"; started_at: string; finished_at: string | null; } /** The fields every Scheduler row has from its runs: how the newest ended one went, and the few * before it. */ export interface RunFields { status: RunOutcome | null; last_started: string | null; last_finished: string | null; last_result: string | null; // the English one-liner — the fallback for a job with no `result` // The last result as data: counts, a `skipped` / `reason` word, a nested dict per phase — // named in the reader's language by lib/jobResult. result: JobResult | null; last_error: string | null; last_trigger: RunBrief["trigger"] | null; last_run_id: number | null; quota_units: number | null; /** The newest runs, newest first (at most three). */ recent: RunBrief[]; } export interface SchedulerJob extends RunFields { id: string; interval_minutes: number; next_run: string | null; running: boolean; // Live progress while running (null when idle or for jobs that don't report it). // total is null for indeterminate progress (e.g. enrichment drains an unknown queue). progress: { current: number; total: number | null; phase: string | null } | null; } /** Work a user starts on one target (a channel's full history): no interval, no "run now". */ export interface SchedulerAdHoc extends RunFields { id: string; running: boolean; target: string | null; target_title: string | null; } export interface SchedulerStatus { running_here: boolean; enabled: boolean; paused: boolean; jobs: SchedulerJob[]; ad_hoc: SchedulerAdHoc[]; queue: { channels_recent_pending: number; channels_deep_pending: number; deep_eta_seconds: number; videos_pending_enrich: number; videos_pending_shorts: number; videos_live_refresh: number; }; quota: { used_today: number; remaining_today: number; daily_budget: number; backfill_reserve: number; }; maintenance: { revalidate_batch: number; revalidate_batch_default: number; min: number; max: number; }; } export const adminApi = { // --- discovery / system configuration --- // Admin: reclaim all un-kept discovery content (search results + explored channels) now. purgeDiscovery: (): Promise> => req("/api/admin/purge-discovery", { method: "POST" }), adminConfig: (): Promise => req("/api/admin/config"), setConfig: (key: string, value: string | number | boolean): Promise => req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }), resetConfig: (key: string): Promise => req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), /** The bug-report tracker: answers, takes issues, and is private. */ testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> => req("/api/admin/config/test-issues", { method: "POST" }), // --- users & roles --- adminAudit: (query: AuditQuery, before: string | null): Promise> => req(`/api/admin/audit${queryString({ ...query, before })}`), adminAuditFacets: (query: AuditQuery): Promise<{ actions: LogFacet[] }> => req(`/api/admin/audit/facets${queryString(query)}`), adminRuns: (query: RunQuery, before: string | null): Promise> => req(`/api/admin/runs${queryString({ ...query, before })}`), adminRunFacets: (query: RunQuery): Promise => req(`/api/admin/runs/facets${queryString(query)}`), clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), // --- diagnostics console --- adminLogs: (query: LogQuery, before: string | null): Promise => req(`/api/admin/logs${logParams(query, { before })}`), adminLogFacets: (query: LogQuery): Promise => req(`/api/admin/logs/facets${logParams(query)}`), adminUsers: (): Promise => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), setUserSuspended: (id: number, suspended: boolean): Promise => req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }), adminDeleteUser: (id: number): Promise<{ deleted: number }> => req(`/api/admin/users/${id}`, { method: "DELETE" }), // --- scheduler + maintenance --- schedulerStatus: (): Promise => req("/api/admin/scheduler"), updateSchedulerJob: ( jobId: string, intervalMinutes: number, ): Promise<{ id: string; interval_minutes: number }> => req(`/api/admin/scheduler/jobs/${jobId}`, { method: "PATCH", body: JSON.stringify({ interval_minutes: intervalMinutes }), }), runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> => req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), runAllSchedulerJobs: (): Promise<{ started: string[] }> => req("/api/admin/scheduler/run-all", { method: "POST" }), updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> => req("/api/admin/scheduler/maintenance", { method: "PATCH", body: JSON.stringify({ revalidate_batch: revalidateBatch }), }), // --- access invites --- // Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25. adminPendingAccess: (): Promise => req("/api/admin/pending-access"), // Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing // was always possible by re-entering the address in the whitelist box. activateUser: (id: number): Promise => req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }), // Re-send someone's confirmation link. Unlike the public resend this one says what happened, // because the caller is already an authenticated admin. resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> => req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }), // What the account panel shows: the two computed gate answers, resolved for one user on open. adminUserAccess: (id: number): Promise => req(`/api/admin/users/${id}/access`), // Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the // everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password. createSetPasswordLink: ( id: number, ): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> => req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }), adminInvites: (status?: string): Promise => req(`/api/admin/invites${status ? `?status=${status}` : ""}`), approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }), denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }), // Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so // this is the deliberate act that actually reaches a person. sendInvitation: (id: number, lang: string): Promise => req( `/api/admin/invites/${id}/invite`, { method: "POST", body: JSON.stringify({ lang }) }, { quiet: true }, ), addInvite: (email: string) => req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }), /** `still_allowed_by_env` is true when the address stays allow-listed by the server's env * ALLOWED_EMAILS / ADMIN_EMAILS, i.e. removing the row did not actually revoke access. */ // quiet: a 409 here ("that address has an account") is a case the UI handles with a translated // toast; without this the shared handler ALSO raises the server's English detail as a modal. deleteInvite: (id: number): Promise<{ deleted: number; still_allowed_by_env: boolean }> => req(`/api/admin/invites/${id}`, { method: "DELETE" }, { quiet: true }), };