import { sanitizeNotificationChannel, sanitizeNotificationLogEntry, sanitizeNotificationQueueEntry, sanitizeNotificationRule, sanitizeNotificationCenterSnapshot, } from './notification-center-sanitize.js'; import type { NotificationChannel, NotificationChannelDraft, NotificationCenterDataSource, NotificationCenterSnapshot, NotificationLogEntry, NotificationLogCleanup, NotificationLogFilter, NotificationPage, NotificationQueueEntry, NotificationRule, NotificationRuleDraft, } from './notification-center-types.js'; import { logFilterQuery, sanitizeLogCleanup } from './notification-center-types.js'; const BASE = '/api/v1/notifications'; export const NOTIFICATION_PAGE_SIZE = 20; interface CallInit { readonly method: string; readonly body?: string | undefined; readonly headers?: Readonly> | undefined; readonly signal?: AbortSignal | undefined; } async function requestJson( fetcher: typeof fetch, path: string, init: CallInit = { method: 'GET' }, ): Promise { const response = await fetcher(path, { method: init.method, ...(init.body !== undefined ? { body: init.body } : {}), ...(init.signal !== undefined ? { signal: init.signal } : {}), credentials: 'same-origin', headers: { accept: 'application/json', ...(init.headers ?? {}) }, }); if (!response.ok) throw new Error(`请求失败 (${response.status})`); if (response.status === 204) return undefined; try { return (await response.json()) as unknown; } catch { return undefined; } } function items(value: unknown): readonly unknown[] { const raw = value !== null && typeof value === 'object' ? (value as Record).items : undefined; return Array.isArray(raw) ? raw.slice(0, 500) : []; } function total(value: unknown): number { if (value === null || typeof value !== 'object') return 0; const page = (value as Record).page; if (page === null || typeof page !== 'object') return 0; const count = (page as Record).total; return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0; } function affected(value: unknown): number { if (value === null || typeof value !== 'object') return 0; const count = (value as Record).affected; return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0; } const jsonHeaders = { 'content-type': 'application/json' }; export function createNotificationCenterApiDataSource( fetcher: typeof fetch = fetch, ): NotificationCenterDataSource { return { async loadSnapshot(signal?: AbortSignal): Promise { const payload = await requestJson(fetcher, `${BASE}/overview`, { method: 'GET', signal }); const snapshot = sanitizeNotificationCenterSnapshot(payload); if (!snapshot) throw new Error('通知总览响应无效。'); return snapshot; }, async listChannels(signal?: AbortSignal): Promise { const payload = await requestJson(fetcher, `${BASE}/channels`, { method: 'GET', signal }); return items(payload) .map(sanitizeNotificationChannel) .filter((item): item is NotificationChannel => item !== undefined); }, async saveChannel(draft: NotificationChannelDraft): Promise { const body = JSON.stringify({ name: draft.name, type: draft.type, enabled: draft.enabled, config: draft.config, }); const payload = draft.id ? await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(draft.id)}`, { method: 'PUT', body, headers: jsonHeaders, }) : await requestJson(fetcher, `${BASE}/channels`, { method: 'POST', body, headers: jsonHeaders, }); const channel = sanitizeNotificationChannel(payload); if (!channel) throw new Error('通道响应无效。'); return channel; }, async deleteChannel(id: string): Promise { await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(id)}`, { method: 'DELETE', }); }, async testChannel(id: string): Promise<{ readonly ok: boolean; readonly detail?: string }> { const payload = await requestJson( fetcher, `${BASE}/channels/${encodeURIComponent(id)}/test`, { method: 'POST', body: '{}', headers: jsonHeaders, }, ); const source = payload !== null && typeof payload === 'object' ? (payload as Record) : {}; const detail = typeof source.detail === 'string' ? source.detail.slice(0, 500) : ''; return Object.freeze({ ok: source.ok === true, ...(detail ? { detail } : {}) }); }, async listRules(signal?: AbortSignal): Promise { const payload = await requestJson(fetcher, `${BASE}/rules?pageSize=100`, { method: 'GET', signal, }); return items(payload) .map(sanitizeNotificationRule) .filter((item): item is NotificationRule => item !== undefined); }, async saveRule(draft: NotificationRuleDraft): Promise { const body = JSON.stringify({ name: draft.name, eventType: draft.eventType, enabled: draft.enabled, condition: { field: draft.condition.field, mode: draft.condition.mode, ...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }), }, scope: draft.scope.mode === 'groups' ? { mode: 'groups', groups: [...draft.scope.groups] } : draft.scope.mode === 'tags' ? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match } : draft.scope.mode === 'devices' ? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] } : { mode: 'all' }, channelIds: [...draft.channelIds], templates: { title: draft.templates.title, body: draft.templates.body }, ...(draft.rateLimit === undefined ? {} : { rateLimit: draft.rateLimit }), ...(draft.quietHours === undefined ? {} : { quietHours: [...draft.quietHours] }), }); const payload = draft.id ? await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(draft.id)}`, { method: 'PATCH', body, headers: jsonHeaders, }) : await requestJson(fetcher, `${BASE}/rules`, { method: 'POST', body, headers: jsonHeaders, }); const rule = sanitizeNotificationRule(payload); if (!rule) throw new Error('规则响应无效。'); return rule; }, async deleteRule(id: string): Promise { await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }); }, async listLogs( page: number, filter?: NotificationLogFilter, signal?: AbortSignal, ): Promise> { const payload = await requestJson( fetcher, `${BASE}/logs?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${logFilterQuery(filter)}`, { method: 'GET', signal }, ); return Object.freeze({ items: Object.freeze( items(payload) .map(sanitizeNotificationLogEntry) .filter((item): item is NotificationLogEntry => item !== undefined), ), total: total(payload), }); }, async clearLogs(filter?: NotificationLogFilter): Promise { return affected( await requestJson(fetcher, `${BASE}/logs/clear`, { method: 'POST', body: JSON.stringify(filter ?? {}), headers: jsonHeaders, }), ); }, async getLogCleanup(signal?: AbortSignal): Promise { const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, { method: 'GET', signal, }); return sanitizeLogCleanup(payload); }, async saveLogCleanup(cleanup: NotificationLogCleanup): Promise { const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, { method: 'PUT', body: JSON.stringify({ retentionDaysEnabled: cleanup.retentionDaysEnabled, retentionDays: cleanup.retentionDays, maxEntriesEnabled: cleanup.maxEntriesEnabled, maxEntries: cleanup.maxEntries, }), headers: jsonHeaders, }); return sanitizeLogCleanup(payload); }, async pruneLogs(): Promise { return affected( await requestJson(fetcher, `${BASE}/logs/prune`, { method: 'POST', body: '{}', headers: jsonHeaders, }), ); }, async listQueue( page: number, status?: string, signal?: AbortSignal, ): Promise> { const query = status ? `&status=${encodeURIComponent(status)}` : ''; const payload = await requestJson( fetcher, `${BASE}/queue?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${query}`, { method: 'GET', signal }, ); return Object.freeze({ items: Object.freeze( items(payload) .map(sanitizeNotificationQueueEntry) .filter((item): item is NotificationQueueEntry => item !== undefined), ), total: total(payload), }); }, async retryQueueItem(id: string): Promise { await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}/retry`, { method: 'POST', }); }, async deleteQueueItem(id: string): Promise { await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}`, { method: 'DELETE' }); }, async clearQueue(status?: string): Promise { const query = status ? `?status=${encodeURIComponent(status)}` : ''; return affected(await requestJson(fetcher, `${BASE}/queue${query}`, { method: 'DELETE' })); }, async processQueue(): Promise<{ readonly processed: number; readonly succeeded: number; readonly failed: number; }> { const payload = await requestJson(fetcher, `${BASE}/queue/process`, { method: 'POST' }); const source = payload !== null && typeof payload === 'object' ? (payload as Record) : {}; const number = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; return Object.freeze({ processed: number(source.processed), succeeded: number(source.succeeded), failed: number(source.failed), }); }, }; }