// 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; actor: string | null; // email of the acting admin; null = System (scheduler/background) 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; } export interface AuditListResult { items: AuditRow[]; total: number; // total rows in the log (may exceed `items` if capped) returned: 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; 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; 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, 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}` : ""; } export type JobResult = { [key: string]: number | string | JobResult }; export interface SchedulerJob { id: string; interval_minutes: number; next_run: string | null; running: boolean; status: "ok" | "error" | "skipped" | 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; // 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; } export interface SchedulerStatus { running_here: boolean; enabled: boolean; paused: boolean; jobs: SchedulerJob[]; 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: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), 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 }), };