837aa77bfc
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
251 lines
11 KiB
TypeScript
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);
|