Files
homefeed/frontend/src/lib/adminApi.ts
T
Claude 837aa77bfc Full widget parity, /api/widget/<id> route namespace, live sidebar rendering
Weather, Stocks, and Bookmarks move into backend/src/widgets/<id>/ alongside
PoE2, each a full WidgetPlugin rather than a thin wrapper: their tables are
renamed to the widget_<id>_ convention (stock_tickers, bookmarks) or moved off
global_settings entirely into the generic widget_kv store (weather's
location/unit config and forecast cache). Every widget's routes move under a
consistent /api/widget/<id> (public) and /api/admin/widget/<id> (admin)
namespace, replacing the previous flat /api/weather, /api/admin/poe2/browse,
etc. The registry-management routes (list/upload/enable/delete any widget)
stay at /api/admin/widgets since they address the collection, not one
widget's own data.

This also closes the gap where an uploaded widget had backend data plumbing
but no visible presence anywhere: a widget's poll() can now publish to a
generic GET /api/widget/:id/report feed (registered once, works for any id
with zero per-widget route registration, so it's live immediately after
upload with no restart); the sidebar renders it via a new GenericWidgetCard,
or via a new DynamicWidgetSlot that dynamic-imports an optional pre-built
vanilla-JS frontend bundle the widget can ship (served from a new
/widget-assets/:id/* route) — plain browser import(), not blocked by
SvelteKit's ahead-of-time Svelte compilation the way raw .svelte source would
be. A widget's own custom API routes still need a restart to register
(a hard Fastify limitation), but its data/poll/report and any custom frontend
UI now work fully live. The admin Widgets tab gained an upload form and a
list of installed pluggable widgets with enable/delete.

Verified against a copy of the real dev DB (all three renames + the weather
config/cache migration fire once and are idempotent on a second boot) and
through a real browser: uploaded a widget with both a report-driven poll and
a custom frontend bundle, confirmed it renders live in the sidebar with no
backend restart, then deleted it and confirmed it disappears along with all
of its data (table/kv rows/on-disk files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-27 01:16:47 +00:00

251 lines
11 KiB
TypeScript

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<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
// 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<string, string> = { ...(options.headers as Record<string, string> | 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<void> {
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<void> {
clearApiKey();
}
// Settings
export const getSettings = (fetchFn?: typeof fetch) =>
request<AdminSettings>('/api/admin/settings', {}, fetchFn);
export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof fetch) =>
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
// Categories
export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) =>
request<CategoryPriority>(
'/api/admin/categories',
{ method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
fetchFn
);
export const deleteCategory = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/categories/${id}`, { method: 'DELETE' }, fetchFn);
// Sources
export const getSources = (fetchFn?: typeof fetch) =>
request<AdminSource[]>('/api/admin/sources', {}, fetchFn);
export const addSource = (source: Partial<AdminSource>, fetchFn?: typeof fetch) =>
request<AdminSource>('/api/admin/sources', { method: 'POST', body: JSON.stringify(source) }, fetchFn);
export const updateSource = (id: string, patch: Partial<AdminSource>, fetchFn?: typeof fetch) =>
request<AdminSource>(`/api/admin/sources/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteSource = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/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<AdminTrackedEvent[]>('/api/admin/events', {}, fetchFn);
export const addEvent = (event: Partial<AdminTrackedEvent>, fetchFn?: typeof fetch) =>
request<AdminTrackedEvent>('/api/admin/events', { method: 'POST', body: JSON.stringify(event) }, fetchFn);
export const updateEvent = (id: string, patch: Partial<AdminTrackedEvent>, fetchFn?: typeof fetch) =>
request<AdminTrackedEvent>(`/api/admin/events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteEvent = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/events/${id}`, { method: 'DELETE' }, fetchFn);
// Models / AI service
export const getModels = (fetchFn?: typeof fetch) =>
request<ModelCatalog>('/api/admin/models', {}, fetchFn);
export const getAiStatus = (fetchFn?: typeof fetch) =>
request<AiStatus>('/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<TelegramStatus>('/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<TelegramStatus>('/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<string, string>).toString();
return request<LogEntry[]>(`/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<AdminWeatherSettings>('/api/admin/widget/weather', {}, fetchFn);
export const updateWeatherConfig = (patch: Partial<AdminWeatherSettings>, fetchFn?: typeof fetch) =>
request<AdminWeatherSettings>('/api/admin/widget/weather', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const geocodeLocation = (query: string, fetchFn?: typeof fetch) =>
request<GeocodeResult[]>(`/api/admin/widget/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
// Stocks
export const getStockTickers = (fetchFn?: typeof fetch) =>
request<AdminStockTicker[]>('/api/admin/widget/stocks', {}, fetchFn);
export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) =>
request<AdminStockTicker>('/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<AdminStockTicker>(`/api/admin/widget/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/widget/stocks/${id}`, { method: 'DELETE' }, fetchFn);
// Bookmarks
export const getAdminBookmarks = (fetchFn?: typeof fetch) =>
request<AdminBookmark[]>('/api/admin/widget/bookmarks', {}, fetchFn);
export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) =>
request<AdminBookmark>(
'/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<AdminBookmark>(`/api/admin/widget/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/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<Poe2BrowseEntry[]>('/api/admin/widget/poe2/browse', {}, fetchFn);
export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
request<AdminPoe2Entry[]>('/api/admin/widget/poe2/watchlist', {}, fetchFn);
export const addPoe2WatchlistEntry = (
base: { currencyId: string; name: string },
quote: { currencyId: string; name: string },
fetchFn?: typeof fetch
) =>
request<AdminPoe2Entry>(
'/api/admin/widget/poe2/watchlist',
{ method: 'POST', body: JSON.stringify({ base, quote }) },
fetchFn
);
export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/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<InstalledWidget[]>('/api/admin/widgets', {}, fetchFn);
export const installWidget = (manifest: WidgetUploadManifest, files: Record<string, string>, 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<InstalledWidget>(`/api/admin/widgets/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }) }, fetchFn);
export const deleteWidget = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/widgets/${id}`, { method: 'DELETE' }, fetchFn);