Files
siftlode/frontend/src/lib/api.ts
T
peter 8ce1e0230a refactor(api): extract 8 content domains into api/*.ts slices (R7.5 S1)
Move feed, channels, sync, playlists, tags, notifications, saved-views and
quota out of the lib/api.ts god object into per-domain slice modules, each
owning its own types + method slice and spread into the façade. The `api`
object shape and all 54 call sites are unchanged; account/auth/setup/admin
stay inline until S2.

Reunites the interleaved tags read/CRUD that motivated the epic.
2026-07-28 22:41:34 +02:00

373 lines
15 KiB
TypeScript

import {
req,
setupHeaders,
getActiveAccount,
HttpError,
setActiveAccount,
clearActiveAccount,
setUnauthorizedHandler,
} from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export {
HttpError,
getActiveAccount,
setActiveAccount,
clearActiveAccount,
setUnauthorizedHandler,
};
// Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing
// them from "./api" unchanged; their method slices stay internal (`export type *`, not `export *`)
// and reach consumers only through the `api` façade they're spread into below. Runtime value
// exports a domain genuinely owns (e.g. plex's EMPTY_PLEX_FILTERS/plexFilterCount) are re-exported
// explicitly.
import { feedApi } from "./api/feed";
export type * from "./api/feed";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
export type * from "./api/sync";
import { playlistsApi } from "./api/playlists";
export type * from "./api/playlists";
import { tagsApi } from "./api/tags";
export type * from "./api/tags";
import { notificationsApi } from "./api/notifications";
export type * from "./api/notifications";
import { savedViewsApi } from "./api/savedViews";
export type * from "./api/savedViews";
import { quotaApi } from "./api/quota";
export type * from "./api/quota";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { plexApi } from "./api/plex";
export type * from "./api/plex";
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
// ---------------------------------------------------------------------------
// The domains still living inline here — account, auth, setup, admin — are
// extracted in R7.5 S2. Their types and methods stay together in this file
// until then.
// ---------------------------------------------------------------------------
export interface Me {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
role: string;
is_demo: boolean;
has_google: boolean;
has_password: boolean;
google_enabled: boolean;
plex_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
preferences: Record<string, unknown>;
}
export interface DemoWhitelistEntry {
id: number;
email: string;
note: string | null;
added_by: string | null;
created_at: string | null;
}
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;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
build_date: string | null;
db_revision: string | null;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
key: string;
type: "int" | "str" | "bool";
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
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<string, ConfigItem[]>;
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;
is_demo: boolean;
has_password: boolean;
has_google: boolean;
created_at: string | null;
}
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.<action>
target_type: string | null;
target_id: string | null;
summary: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
}
export interface AuditListResult {
items: AuditRow[];
total: number; // total rows in the log (may exceed `items` if capped)
returned: number;
}
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;
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 api = {
// --- account & session ---
// Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
// logs a failed request to the console). React Query treats the null as data, not an error.
me: async (): Promise<Me | null> => {
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, unknown>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
// --- admin: discovery / system configuration ---
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
purgeDiscovery: (): Promise<Record<string, number>> =>
req("/api/admin/purge-discovery", { method: "POST" }),
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
resetConfig: (key: string): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
// --- admin: users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
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" }),
schedulerStatus: (): Promise<SchedulerStatus> => 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 }),
}),
// --- auth: public config ---
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{
google_enabled: boolean;
allow_registration: boolean;
operator_contact: string | null;
}> => req("/auth/config"),
// --- first-run install wizard (token from the setup URL printed to the container logs) ---
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/admin",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
{ quiet: true },
),
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
req(
"/api/setup/config",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
{ quiet: true },
),
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{ quiet: true },
),
setupFinish: (token: string): Promise<{ ok: boolean }> =>
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
// --- auth: email + password (errors handled inline via quiet) ---
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req(
"/auth/password-reset/request",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/confirm",
{ method: "POST", body: JSON.stringify({ token, password }) },
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
// reveals whether the email has a pending account.
resendVerification: (email: string): Promise<{ status: string }> =>
req(
"/auth/verify/resend",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true },
),
// --- onboarding / admin: access requests, demo ---
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
adminInvites: (status?: string): Promise<Invite[]> =>
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" }),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
// --- extracted domain slices (their methods reach consumers through this façade) ---
...feedApi,
...channelsApi,
...syncApi,
...playlistsApi,
...tagsApi,
...notificationsApi,
...savedViewsApi,
...quotaApi,
...plexApi,
...messagingApi,
...downloadsApi,
};