import { getBackendUrl } from './config'; import { getApiKey, setApiKey, clearApiKey } from './adminAuth'; import type { AdminSettings, AdminSource, AdminTrackedEvent, CategoryPriority, ModelCatalog, AiStatus, TelegramStatus, LogEntry, GeocodeResult, AdminStockTicker, AdminBookmark, Poe2BrowseEntry, AdminPoe2Entry, AdminWeatherSettings, InstalledWidget, WidgetUploadManifest } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { // Fastify's default JSON body parser rejects an empty body when Content-Type is // application/json ("Body cannot be empty when content-type is set to // 'application/json'") — so this header is only attached when there's actually a // body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls. const headers: Record = { ...(options.headers as Record | undefined) }; if (options.body) headers['Content-Type'] = 'application/json'; const apiKey = getApiKey(); if (apiKey) headers['X-Api-Key'] = apiKey; const res = await fetchFn(`${getBackendUrl()}${path}`, { ...options, headers }); if (res.status === 401) { const err = new Error('unauthorized') as Error & { status?: number }; err.status = 401; throw err; } if (!res.ok) throw new Error(`Admin request failed: ${path} (${res.status})`); if (res.status === 204) return undefined as T; return res.json(); } // Auth — there's no backend session to create; "logging in" means storing the // entered key locally and confirming it actually works with one real authenticated // call (getSettings has no side effects), and "logging out" is just discarding it. export async function login(apiKey: string, fetchFn: typeof fetch = fetch): Promise { setApiKey(apiKey); try { await getSettings(fetchFn); } catch (err) { clearApiKey(); if ((err as { status?: number }).status === 401) throw new Error('Invalid API key'); throw err; } } export async function logout(): Promise { clearApiKey(); } // Settings export const getSettings = (fetchFn?: typeof fetch) => request('/api/admin/settings', {}, fetchFn); export const updateSettings = (patch: Partial, fetchFn?: typeof fetch) => request('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); // Categories export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) => request( '/api/admin/categories', { method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) }, fetchFn ); export const deleteCategory = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/categories/${id}`, { method: 'DELETE' }, fetchFn); // Sources export const getSources = (fetchFn?: typeof fetch) => request('/api/admin/sources', {}, fetchFn); export const addSource = (source: Partial, fetchFn?: typeof fetch) => request('/api/admin/sources', { method: 'POST', body: JSON.stringify(source) }, fetchFn); export const updateSource = (id: string, patch: Partial, fetchFn?: typeof fetch) => request(`/api/admin/sources/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteSource = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/sources/${id}`, { method: 'DELETE' }, fetchFn); export const pollSourceNow = (id: string, fetchFn?: typeof fetch) => request<{ ingested: number; source: AdminSource }>(`/api/admin/sources/${id}/poll`, { method: 'POST' }, fetchFn); // Deletes this source's published articles and requeues their raw items for republish — // picks up pipeline changes without needing the feed to resurface the same items. export const reissueSourceContent = (id: string, fetchFn?: typeof fetch) => request<{ articlesDeleted: number; itemsRequeued: number }>(`/api/admin/sources/${id}/reissue`, { method: 'POST' }, fetchFn); // Content clearing — wipe articles/media/a source's raw items so they can be repopulated fresh. export const clearSourceContent = (id: string, fetchFn?: typeof fetch) => request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn); export const clearAllArticles = (fetchFn?: typeof fetch) => request<{ deleted: number }>('/api/admin/content/articles', { method: 'DELETE' }, fetchFn); export const clearAllMedia = (fetchFn?: typeof fetch) => request<{ deleted: number }>('/api/admin/content/media', { method: 'DELETE' }, fetchFn); // Tracked events export const getEvents = (fetchFn?: typeof fetch) => request('/api/admin/events', {}, fetchFn); export const addEvent = (event: Partial, fetchFn?: typeof fetch) => request('/api/admin/events', { method: 'POST', body: JSON.stringify(event) }, fetchFn); export const updateEvent = (id: string, patch: Partial, fetchFn?: typeof fetch) => request(`/api/admin/events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteEvent = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/events/${id}`, { method: 'DELETE' }, fetchFn); // Models / AI service export const getModels = (fetchFn?: typeof fetch) => request('/api/admin/models', {}, fetchFn); export const getAiStatus = (fetchFn?: typeof fetch) => request('/api/admin/ai-status', {}, fetchFn); // Telegram account (Connections tab) — API ID/hash and the resulting login session are // stored encrypted at rest server-side (see backend telegram/credentials.ts); none of // these ever come back from the server, only status flags. export const getTelegramStatus = (fetchFn?: typeof fetch) => request('/api/admin/telegram/status', {}, fetchFn); export const saveTelegramCredentials = (apiId: number, apiHash: string, fetchFn?: typeof fetch) => request<{ credentialsConfigured: boolean }>( '/api/admin/telegram/credentials', { method: 'POST', body: JSON.stringify({ apiId, apiHash }) }, fetchFn ); export const startTelegramLogin = (phoneNumber: string, fetchFn?: typeof fetch) => request<{ phase: 'code-sent' }>( '/api/admin/telegram/login/start', { method: 'POST', body: JSON.stringify({ phoneNumber }) }, fetchFn ); export const verifyTelegramCode = (code: string, fetchFn?: typeof fetch) => request<{ phase: 'connected' | 'password-needed'; connected?: boolean; phone?: string | null }>( '/api/admin/telegram/login/verify', { method: 'POST', body: JSON.stringify({ code }) }, fetchFn ); export const verifyTelegramPassword = (password: string, fetchFn?: typeof fetch) => request<{ phase: 'connected'; connected?: boolean; phone?: string | null }>( '/api/admin/telegram/login/verify', { method: 'POST', body: JSON.stringify({ password }) }, fetchFn ); export const telegramLogout = (fetchFn?: typeof fetch) => request('/api/admin/telegram/logout', { method: 'POST' }, fetchFn); // Logs export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: number } = {}, fetchFn?: typeof fetch) => { const qs = new URLSearchParams(filters as Record).toString(); return request(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn); }; // Weather — config/cache now live behind the widget's own dedicated admin route (see // backend/src/widgets/weather/plugin.ts) rather than riding along on AdminSettings. export const getWeatherConfig = (fetchFn?: typeof fetch) => request('/api/admin/widget/weather', {}, fetchFn); export const updateWeatherConfig = (patch: Partial, fetchFn?: typeof fetch) => request('/api/admin/widget/weather', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const geocodeLocation = (query: string, fetchFn?: typeof fetch) => request(`/api/admin/widget/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn); // Stocks export const getStockTickers = (fetchFn?: typeof fetch) => request('/api/admin/widget/stocks', {}, fetchFn); export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) => request('/api/admin/widget/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn); export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) => request(`/api/admin/widget/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/widget/stocks/${id}`, { method: 'DELETE' }, fetchFn); // Bookmarks export const getAdminBookmarks = (fetchFn?: typeof fetch) => request('/api/admin/widget/bookmarks', {}, fetchFn); export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) => request( '/api/admin/widget/bookmarks', { method: 'POST', body: JSON.stringify({ name, url, isPrivate }) }, fetchFn ); export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) => request(`/api/admin/widget/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteBookmark = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); // PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts). export const browsePoe2Currencies = (fetchFn?: typeof fetch) => request('/api/admin/widget/poe2/browse', {}, fetchFn); export const getPoe2Watchlist = (fetchFn?: typeof fetch) => request('/api/admin/widget/poe2/watchlist', {}, fetchFn); export const addPoe2WatchlistEntry = ( base: { currencyId: string; name: string }, quote: { currencyId: string; name: string }, fetchFn?: typeof fetch ) => request( '/api/admin/widget/poe2/watchlist', { method: 'POST', body: JSON.stringify({ base, quote }) }, fetchFn ); export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/widget/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn); // Pluggable widgets (upload/list/enable/delete) — see backend/src/widgets/install.ts, // uninstall.ts. Built-in widgets (source: 'builtin') 400 on deleteWidget. export const listWidgets = (fetchFn?: typeof fetch) => request('/api/admin/widgets', {}, fetchFn); export const installWidget = (manifest: WidgetUploadManifest, files: Record, fetchFn?: typeof fetch) => request<{ id: string }>('/api/admin/widgets', { method: 'POST', body: JSON.stringify({ manifest, files }) }, fetchFn); export const setWidgetEnabled = (id: string, enabled: boolean, fetchFn?: typeof fetch) => request(`/api/admin/widgets/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }) }, fetchFn); export const deleteWidget = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/widgets/${id}`, { method: 'DELETE' }, fetchFn);