diff --git a/deno.json b/deno.json index 62995fd..c9654c0 100644 --- a/deno.json +++ b/deno.json @@ -11,7 +11,9 @@ "$http/": "./src/utils/http/", "$api": "./src/utils/api/request.ts", "$utils/": "./src/utils/", - "@std/assert": "jsr:@std/assert@^1.0.0" + "$notifications/": "./src/notifications/", + "@std/assert": "jsr:@std/assert@^1.0.0", + "simple-icons": "npm:simple-icons@^15.17.0" }, "tasks": { "dev": "APP_BASE_PATH=./temp vite dev", diff --git a/package.json b/package.json index e1c0c6a..f377332 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@jsr/db__sqlite": "^0.12.0", "lucide-svelte": "^0.546.0", + "simple-icons": "^15.17.0", "sveltekit-adapter-deno": "^0.16.1" }, "devDependencies": { diff --git a/src/components/notifications/DiscordConfiguration.svelte b/src/components/notifications/DiscordConfiguration.svelte new file mode 100644 index 0000000..9b5fd9d --- /dev/null +++ b/src/components/notifications/DiscordConfiguration.svelte @@ -0,0 +1,109 @@ + + +
+

+ Discord Configuration +

+ +
+ +
+ + +

+ {#if mode === 'edit'} + Leave blank to keep existing webhook URL + {:else} + Get this from Server Settings → Integrations → Webhooks in Discord + {/if} +

+
+ + +
+ + +

+ Custom username for the webhook bot +

+
+ + +
+ + +

+ Custom avatar image for the webhook bot +

+
+ + +
+ +
+ +

+ Mention @here in notifications to alert online users +

+
+
+
+
diff --git a/src/components/notifications/NotificationHistory.svelte b/src/components/notifications/NotificationHistory.svelte new file mode 100644 index 0000000..9b66327 --- /dev/null +++ b/src/components/notifications/NotificationHistory.svelte @@ -0,0 +1,137 @@ + + +
+ +
+
+ +

+ Recent Notifications +

+
+
+ + +
+ + + + + + + + + + + + {#each history as record (record.id)} + + + + + + + + + + + + + + + + + {:else} + + + + {/each} + +
+ Time + + Service + + Type + + Title + + Status +
+ {formatDateTime(record.sent_at)} + + {getServiceName(record.service_id)} + + + {formatNotificationType(record.notification_type)} + + + {record.title} + + {#if record.status === 'success'} + + Success + + {:else} + + Failed + + {/if} +
+ No notification history available yet. +
+
+
diff --git a/src/components/notifications/NotificationServiceForm.svelte b/src/components/notifications/NotificationServiceForm.svelte new file mode 100644 index 0000000..6752d0c --- /dev/null +++ b/src/components/notifications/NotificationServiceForm.svelte @@ -0,0 +1,200 @@ + + +
{ + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + toastStore.add( + 'error', + (result.data as { error?: string }).error || `Failed to ${mode} service` + ); + } else if (result.type === 'redirect') { + toastStore.add('success', `Notification service ${mode === 'create' ? 'created' : 'updated'} successfully`); + } + await update(); + }; + }} + class="space-y-6" +> + +
+

+ Basic Settings +

+ +
+ +
+ +
+
+ {#if selectedType === 'discord'} + + + + {/if} +
+ +
+ {#if mode === 'edit'} +

+ Service type cannot be changed after creation +

+ {/if} +
+ + +
+ + +

+ A friendly name to identify this notification service +

+
+
+
+ + + {#if selectedType === 'discord'} + + {/if} + + +
+

+ Notification Types +

+

+ Select which types of notifications should be sent to this service +

+ +
+ {#each Object.entries(groupedTypes) as [category, types]} +
+

+ {category} +

+
+ {#each types as type} +
+ + +
+ {/each} +
+
+ {/each} +
+
+ + +
+ + Cancel + + +
+ diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 681848b..56a4fe0 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -8,6 +8,7 @@ import { migration as migration003 } from './migrations/003_create_log_settings. import { migration as migration004 } from './migrations/004_create_jobs_tables.ts'; import { migration as migration005 } from './migrations/005_create_backup_settings.ts'; import { migration as migration006 } from './migrations/006_simplify_log_settings.ts'; +import { migration as migration007 } from './migrations/007_create_notification_tables.ts'; export interface Migration { version: number; @@ -231,7 +232,8 @@ export function loadMigrations(): Migration[] { migration003, migration004, migration005, - migration006 + migration006, + migration007 ]; // Sort by version number diff --git a/src/db/migrations/007_create_notification_tables.ts b/src/db/migrations/007_create_notification_tables.ts new file mode 100644 index 0000000..1ab1d37 --- /dev/null +++ b/src/db/migrations/007_create_notification_tables.ts @@ -0,0 +1,79 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 007: Create notification tables + * + * Creates two tables: + * - notification_services: Store notification service configurations (Discord, Slack, etc.) + * - notification_history: Track notification delivery history for auditing + */ + +export const migration: Migration = { + version: 7, + name: 'Create notification tables', + + up: ` + -- Create notification_services table + CREATE TABLE notification_services ( + id TEXT PRIMARY KEY, -- UUID + + -- Service identification + name TEXT NOT NULL, -- User-defined: "Main Discord", "Error Alerts" + service_type TEXT NOT NULL, -- 'discord', 'slack', 'email', etc. + + -- Configuration + enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch + config TEXT NOT NULL, -- JSON blob: { webhook_url: "...", username: "...", ... } + enabled_types TEXT NOT NULL, -- JSON array: ["job.backup.success", "job.backup.failed"] + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Create notification_history table + CREATE TABLE notification_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Foreign key to notification service + service_id TEXT NOT NULL, + + -- Notification details + notification_type TEXT NOT NULL, -- e.g., 'job.backup.success' + title TEXT NOT NULL, + message TEXT NOT NULL, + metadata TEXT, -- JSON blob for additional context + + -- Delivery status + status TEXT NOT NULL CHECK (status IN ('success', 'failed')), + error TEXT, -- Error message if status = 'failed' + + -- Timing + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (service_id) REFERENCES notification_services(id) ON DELETE CASCADE + ); + + -- Create indexes for notification_services + CREATE INDEX idx_notification_services_enabled ON notification_services(enabled); + CREATE INDEX idx_notification_services_type ON notification_services(service_type); + + -- Create indexes for notification_history + CREATE INDEX idx_notification_history_service_id ON notification_history(service_id); + CREATE INDEX idx_notification_history_sent_at ON notification_history(sent_at); + CREATE INDEX idx_notification_history_status ON notification_history(status); + `, + + down: ` + -- Drop indexes first + DROP INDEX IF EXISTS idx_notification_history_status; + DROP INDEX IF EXISTS idx_notification_history_sent_at; + DROP INDEX IF EXISTS idx_notification_history_service_id; + DROP INDEX IF EXISTS idx_notification_services_type; + DROP INDEX IF EXISTS idx_notification_services_enabled; + + -- Drop tables + DROP TABLE IF EXISTS notification_history; + DROP TABLE IF EXISTS notification_services; + ` +}; diff --git a/src/db/queries/notificationHistory.ts b/src/db/queries/notificationHistory.ts new file mode 100644 index 0000000..fc0824c --- /dev/null +++ b/src/db/queries/notificationHistory.ts @@ -0,0 +1,197 @@ +import { db } from '../db.ts'; + +/** + * Types for notification_history table + */ +export interface NotificationHistoryRecord { + id: number; + service_id: string; + notification_type: string; + title: string; + message: string; + metadata: string | null; // JSON string + status: 'success' | 'failed'; + error: string | null; + sent_at: string; +} + +export interface CreateNotificationHistoryInput { + serviceId: string; + notificationType: string; + title: string; + message: string; + metadata?: Record; + status: 'success' | 'failed'; + error?: string; +} + +export interface NotificationHistoryFilters { + serviceId?: string; + notificationType?: string; + status?: 'success' | 'failed'; + limit?: number; + offset?: number; +} + +/** + * All queries for notification_history table + */ +export const notificationHistoryQueries = { + /** + * Create a new notification history record + */ + create(input: CreateNotificationHistoryInput): boolean { + const affected = db.execute( + `INSERT INTO notification_history ( + service_id, notification_type, title, message, metadata, status, error + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + input.serviceId, + input.notificationType, + input.title, + input.message, + input.metadata ? JSON.stringify(input.metadata) : null, + input.status, + input.error ?? null + ); + + return affected > 0; + }, + + /** + * Get notification history with optional filters + */ + getHistory(filters?: NotificationHistoryFilters): NotificationHistoryRecord[] { + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (filters?.serviceId) { + conditions.push('service_id = ?'); + params.push(filters.serviceId); + } + + if (filters?.notificationType) { + conditions.push('notification_type = ?'); + params.push(filters.notificationType); + } + + if (filters?.status) { + conditions.push('status = ?'); + params.push(filters.status); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const limit = filters?.limit ?? 100; + const offset = filters?.offset ?? 0; + + return db.query( + `SELECT * FROM notification_history ${whereClause} ORDER BY sent_at DESC LIMIT ? OFFSET ?`, + ...params, + limit, + offset + ); + }, + + /** + * Get recent notification history (last 50 by default) + */ + getRecent(limit: number = 50): NotificationHistoryRecord[] { + return db.query( + 'SELECT * FROM notification_history ORDER BY sent_at DESC LIMIT ?', + limit + ); + }, + + /** + * Get notification history for a specific service + */ + getByServiceId(serviceId: string, limit: number = 50): NotificationHistoryRecord[] { + return db.query( + 'SELECT * FROM notification_history WHERE service_id = ? ORDER BY sent_at DESC LIMIT ?', + serviceId, + limit + ); + }, + + /** + * Get notification history by type + */ + getByType(notificationType: string, limit: number = 50): NotificationHistoryRecord[] { + return db.query( + 'SELECT * FROM notification_history WHERE notification_type = ? ORDER BY sent_at DESC LIMIT ?', + notificationType, + limit + ); + }, + + /** + * Get failed notifications + */ + getFailed(limit: number = 50): NotificationHistoryRecord[] { + return db.query( + "SELECT * FROM notification_history WHERE status = 'failed' ORDER BY sent_at DESC LIMIT ?", + limit + ); + }, + + /** + * Get statistics for a service + */ + getStats(serviceId?: string): { + total: number; + success: number; + failed: number; + successRate: number; + } { + const whereClause = serviceId ? 'WHERE service_id = ?' : ''; + const params = serviceId ? [serviceId] : []; + + const result = db.queryFirst<{ + total: number; + success: number; + failed: number; + }>( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed + FROM notification_history ${whereClause}`, + ...params + ); + + if (!result || result.total === 0) { + return { total: 0, success: 0, failed: 0, successRate: 0 }; + } + + return { + total: result.total, + success: result.success, + failed: result.failed, + successRate: (result.success / result.total) * 100 + }; + }, + + /** + * Delete old notification history records + */ + deleteOlderThan(days: number): number { + const affected = db.execute( + `DELETE FROM notification_history + WHERE sent_at < datetime('now', '-' || ? || ' days')`, + days + ); + + return affected; + }, + + /** + * Delete all history for a specific service (used when service is deleted) + */ + deleteByServiceId(serviceId: string): number { + const affected = db.execute( + 'DELETE FROM notification_history WHERE service_id = ?', + serviceId + ); + + return affected; + } +}; diff --git a/src/db/queries/notificationServices.ts b/src/db/queries/notificationServices.ts new file mode 100644 index 0000000..2a7bb3e --- /dev/null +++ b/src/db/queries/notificationServices.ts @@ -0,0 +1,167 @@ +import { db } from '../db.ts'; + +/** + * Types for notification_services table + */ +export interface NotificationService { + id: string; + name: string; + service_type: string; + enabled: number; + config: string; // JSON string + enabled_types: string; // JSON array string + created_at: string; + updated_at: string; +} + +export interface NotificationServiceConfig { + [key: string]: unknown; +} + +export interface CreateNotificationServiceInput { + id: string; // UUID generated by caller + name: string; + serviceType: string; + enabled?: boolean; + config: NotificationServiceConfig; + enabledTypes: string[]; +} + +export interface UpdateNotificationServiceInput { + name?: string; + enabled?: boolean; + config?: NotificationServiceConfig; + enabledTypes?: string[]; +} + +/** + * All queries for notification_services table + */ +export const notificationServicesQueries = { + /** + * Get all notification services + */ + getAll(): NotificationService[] { + return db.query('SELECT * FROM notification_services ORDER BY created_at DESC'); + }, + + /** + * Get all enabled notification services + */ + getAllEnabled(): NotificationService[] { + return db.query( + 'SELECT * FROM notification_services WHERE enabled = 1 ORDER BY created_at DESC' + ); + }, + + /** + * Get notification service by ID + */ + getById(id: string): NotificationService | undefined { + return db.queryFirst( + 'SELECT * FROM notification_services WHERE id = ?', + id + ); + }, + + /** + * Get notification services by type + */ + getByType(serviceType: string): NotificationService[] { + return db.query( + 'SELECT * FROM notification_services WHERE service_type = ? ORDER BY created_at DESC', + serviceType + ); + }, + + /** + * Create a new notification service + */ + create(input: CreateNotificationServiceInput): boolean { + const affected = db.execute( + `INSERT INTO notification_services ( + id, name, service_type, enabled, config, enabled_types + ) VALUES (?, ?, ?, ?, ?, ?)`, + input.id, + input.name, + input.serviceType, + input.enabled ? 1 : 0, + JSON.stringify(input.config), + JSON.stringify(input.enabledTypes) + ); + + return affected > 0; + }, + + /** + * Update a notification service + */ + update(id: string, input: UpdateNotificationServiceInput): boolean { + const updates: string[] = []; + const params: (string | number)[] = []; + + if (input.name !== undefined) { + updates.push('name = ?'); + params.push(input.name); + } + if (input.enabled !== undefined) { + updates.push('enabled = ?'); + params.push(input.enabled ? 1 : 0); + } + if (input.config !== undefined) { + updates.push('config = ?'); + params.push(JSON.stringify(input.config)); + } + if (input.enabledTypes !== undefined) { + updates.push('enabled_types = ?'); + params.push(JSON.stringify(input.enabledTypes)); + } + + if (updates.length === 0) { + return false; + } + + // Add updated_at + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); // id for WHERE clause + + const affected = db.execute( + `UPDATE notification_services SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Delete a notification service + */ + delete(id: string): boolean { + const affected = db.execute( + 'DELETE FROM notification_services WHERE id = ?', + id + ); + + return affected > 0; + }, + + /** + * Check if a service name already exists (case-insensitive) + */ + existsByName(name: string, excludeId?: string): boolean { + if (excludeId) { + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM notification_services WHERE LOWER(name) = LOWER(?) AND id != ?', + name, + excludeId + ); + return (result?.count ?? 0) > 0; + } + + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM notification_services WHERE LOWER(name) = LOWER(?)', + name + ); + return (result?.count ?? 0) > 0; + } +}; diff --git a/src/db/schema.sql b/src/db/schema.sql index 1a04449..cdc2080 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,7 +1,7 @@ -- Profilarr Database Schema -- This file documents the current database schema after all migrations -- DO NOT execute this file directly - use migrations instead --- Last updated: 2025-10-21 +-- Last updated: 2025-10-22 -- ============================================================================== -- TABLE: migrations @@ -44,7 +44,7 @@ CREATE TABLE arr_instances ( -- ============================================================================== -- TABLE: log_settings -- Purpose: Store configurable logging settings (singleton pattern with id=1) --- Migration: 003_create_log_settings.ts +-- Migration: 003_create_log_settings.ts, 006_simplify_log_settings.ts -- ============================================================================== CREATE TABLE log_settings ( @@ -140,6 +140,57 @@ CREATE TABLE backup_settings ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- ============================================================================== +-- TABLE: notification_services +-- Purpose: Store notification service configurations (Discord, Slack, Email, etc.) +-- Migration: 007_create_notification_tables.ts +-- ============================================================================== + +CREATE TABLE notification_services ( + id TEXT PRIMARY KEY, -- UUID + + -- Service identification + name TEXT NOT NULL, -- User-defined: "Main Discord", "Error Alerts" + service_type TEXT NOT NULL, -- 'discord', 'slack', 'email', etc. + + -- Configuration + enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch + config TEXT NOT NULL, -- JSON blob: { webhook_url: "...", username: "...", ... } + enabled_types TEXT NOT NULL, -- JSON array: ["job.backup.success", "job.backup.failed"] + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: notification_history +-- Purpose: Track notification delivery history for auditing and debugging +-- Migration: 007_create_notification_tables.ts +-- ============================================================================== + +CREATE TABLE notification_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Foreign key to notification service + service_id TEXT NOT NULL, + + -- Notification details + notification_type TEXT NOT NULL, -- e.g., 'job.backup.success' + title TEXT NOT NULL, + message TEXT NOT NULL, + metadata TEXT, -- JSON blob for additional context + + -- Delivery status + status TEXT NOT NULL CHECK (status IN ('success', 'failed')), + error TEXT, -- Error message if status = 'failed' + + -- Timing + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (service_id) REFERENCES notification_services(id) ON DELETE CASCADE +); + -- ============================================================================== -- INDEXES -- Purpose: Improve query performance @@ -152,3 +203,12 @@ CREATE INDEX idx_jobs_next_run ON jobs(next_run_at); -- Job runs indexes (Migration: 004_create_jobs_tables.ts) CREATE INDEX idx_job_runs_job_id ON job_runs(job_id); CREATE INDEX idx_job_runs_started_at ON job_runs(started_at); + +-- Notification services indexes (Migration: 007_create_notification_tables.ts) +CREATE INDEX idx_notification_services_enabled ON notification_services(enabled); +CREATE INDEX idx_notification_services_type ON notification_services(service_type); + +-- Notification history indexes (Migration: 007_create_notification_tables.ts) +CREATE INDEX idx_notification_history_service_id ON notification_history(service_id); +CREATE INDEX idx_notification_history_sent_at ON notification_history(sent_at); +CREATE INDEX idx_notification_history_status ON notification_history(status); diff --git a/src/jobs/runner.ts b/src/jobs/runner.ts index 5e68ff1..8a817f1 100644 --- a/src/jobs/runner.ts +++ b/src/jobs/runner.ts @@ -1,6 +1,7 @@ import { jobRegistry } from './registry.ts'; import { jobsQueries, jobRunsQueries } from '$db/queries/jobs.ts'; import { logger } from '$logger'; +import { notificationManager } from '../notifications/NotificationManager.ts'; import type { Job, JobResult } from './types.ts'; /** @@ -92,6 +93,27 @@ export async function runJob(job: Job): Promise { }); } + // Send notifications + const notificationType = result.success ? `job.${job.name}.success` : `job.${job.name}.failed`; + const notificationTitle = result.success + ? `${definition.description} - Success` + : `${definition.description} - Failed`; + const notificationMessage = result.success + ? result.output ?? 'Job completed successfully' + : result.error ?? 'Unknown error'; + + await notificationManager.notify({ + type: notificationType, + title: notificationTitle, + message: notificationMessage, + metadata: { + jobId: job.id, + jobName: job.name, + durationMs, + timestamp: finishedAt + } + }); + // Save job run to database jobRunsQueries.create( job.id, diff --git a/src/notifications/NotificationManager.ts b/src/notifications/NotificationManager.ts new file mode 100644 index 0000000..7294cdb --- /dev/null +++ b/src/notifications/NotificationManager.ts @@ -0,0 +1,159 @@ +import { logger } from '$logger'; +import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; +import { notificationHistoryQueries } from '$db/queries/notificationHistory.ts'; +import type { Notifier } from './base/Notifier.ts'; +import type { Notification, DiscordConfig } from './types.ts'; +import { DiscordNotifier } from './notifiers/DiscordNotifier.ts'; + +/** + * Central notification manager + * Orchestrates sending notifications to all enabled services + */ +export class NotificationManager { + /** + * Send a notification to all enabled services that have this notification type enabled + * Fire-and-forget: Does not throw errors, failures are logged + */ + async notify(notification: Notification): Promise { + try { + // Get all enabled services from database + const services = notificationServicesQueries.getAllEnabled(); + + if (services.length === 0) { + await logger.debug('No enabled notification services found', { + source: 'NotificationManager', + meta: { type: notification.type } + }); + return; + } + + // Filter services that have this notification type enabled + const relevantServices = services.filter((service) => { + try { + const enabledTypes = JSON.parse(service.enabled_types) as string[]; + return enabledTypes.includes(notification.type); + } catch { + return false; + } + }); + + if (relevantServices.length === 0) { + await logger.debug('No services configured for this notification type', { + source: 'NotificationManager', + meta: { type: notification.type } + }); + return; + } + + // Send to each service in parallel (fire-and-forget) + await Promise.allSettled( + relevantServices.map((service) => this.sendToService(service.id, service.service_type, service.config, notification)) + ); + } catch (error) { + await logger.error('Error in notification manager', { + source: 'NotificationManager', + meta: { + error: error instanceof Error ? error.message : String(error), + type: notification.type + } + }); + } + } + + /** + * Send notification to a specific service + */ + private async sendToService( + serviceId: string, + serviceType: string, + configJson: string, + notification: Notification + ): Promise { + let success = false; + let errorMessage: string | undefined; + + try { + // Create the appropriate notifier instance + const notifier = this.createNotifier(serviceType, configJson); + + if (!notifier) { + errorMessage = `Unknown service type: ${serviceType}`; + await logger.error(errorMessage, { + source: 'NotificationManager', + meta: { serviceId, serviceType } + }); + return; + } + + // Send the notification + await notifier.notify(notification); + success = true; + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + await logger.error('Failed to send notification to service', { + source: 'NotificationManager', + meta: { + serviceId, + serviceType, + error: errorMessage + } + }); + } finally { + // Record in history + try { + notificationHistoryQueries.create({ + serviceId, + notificationType: notification.type, + title: notification.title, + message: notification.message, + metadata: notification.metadata, + status: success ? 'success' : 'failed', + error: errorMessage + }); + } catch (error) { + await logger.error('Failed to record notification history', { + source: 'NotificationManager', + meta: { + serviceId, + error: error instanceof Error ? error.message : String(error) + } + }); + } + } + } + + /** + * Create a notifier instance based on service type and config + */ + private createNotifier(serviceType: string, configJson: string): Notifier | null { + try { + const config = JSON.parse(configJson); + + switch (serviceType) { + case 'discord': + return new DiscordNotifier(config as DiscordConfig); + // Future services: + // case 'slack': + // return new SlackNotifier(config as SlackConfig); + // case 'email': + // return new EmailNotifier(config as EmailConfig); + default: + return null; + } + } catch (error) { + logger.error('Failed to parse notification service config', { + source: 'NotificationManager', + meta: { + serviceType, + error: error instanceof Error ? error.message : String(error) + } + }); + return null; + } + } +} + +/** + * Singleton instance + */ +export const notificationManager = new NotificationManager(); diff --git a/src/notifications/base/BaseHttpNotifier.ts b/src/notifications/base/BaseHttpNotifier.ts new file mode 100644 index 0000000..c854296 --- /dev/null +++ b/src/notifications/base/BaseHttpNotifier.ts @@ -0,0 +1,99 @@ +import { logger } from '$logger'; +import type { Notification } from '../types.ts'; +import type { Notifier } from './Notifier.ts'; + +/** + * Base class for HTTP-based notification services (webhooks) + * Provides rate limiting and common HTTP functionality + */ +export abstract class BaseHttpNotifier implements Notifier { + private lastSentAt: Date | null = null; + private readonly minInterval: number = 1000; // 1 second between notifications + private readonly timeout: number = 10000; // 10 second timeout + + /** + * Get the webhook URL for this service + */ + protected abstract getWebhookUrl(): string; + + /** + * Format the notification into a service-specific payload + */ + protected abstract formatPayload(notification: Notification): unknown; + + /** + * Get the service name for logging + */ + abstract getName(): string; + + /** + * Send notification via HTTP POST + * Includes rate limiting and error handling + */ + async notify(notification: Notification): Promise { + // Check rate limit + if (this.lastSentAt) { + const elapsed = Date.now() - this.lastSentAt.getTime(); + if (elapsed < this.minInterval) { + await logger.warn('Rate limit hit, skipping notification', { + source: this.getName(), + meta: { elapsed, minInterval: this.minInterval, type: notification.type } + }); + return; + } + } + + try { + const payload = this.formatPayload(notification); + const url = this.getWebhookUrl(); + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + await logger.info(`Notification sent successfully`, { + source: this.getName(), + meta: { type: notification.type, title: notification.title } + }); + + this.lastSentAt = new Date(); + } catch (error) { + clearTimeout(timeoutId); + + // Handle timeout + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + + throw error; + } + } catch (error) { + // Log error but don't throw (fire-and-forget) + await logger.error(`Failed to send notification`, { + source: this.getName(), + meta: { + type: notification.type, + title: notification.title, + error: error instanceof Error ? error.message : String(error) + } + }); + } + } +} diff --git a/src/notifications/base/Notifier.ts b/src/notifications/base/Notifier.ts new file mode 100644 index 0000000..48acd29 --- /dev/null +++ b/src/notifications/base/Notifier.ts @@ -0,0 +1,17 @@ +import type { Notification } from '../types.ts'; + +/** + * Base interface that all notification service implementations must follow + */ +export interface Notifier { + /** + * Send a notification + * This method should be fire-and-forget - errors should be logged but not thrown + */ + notify(notification: Notification): Promise; + + /** + * Get the service name for logging purposes + */ + getName(): string; +} diff --git a/src/notifications/notifiers/DiscordNotifier.ts b/src/notifications/notifiers/DiscordNotifier.ts new file mode 100644 index 0000000..92a5b45 --- /dev/null +++ b/src/notifications/notifiers/DiscordNotifier.ts @@ -0,0 +1,70 @@ +import { BaseHttpNotifier } from '../base/BaseHttpNotifier.ts'; +import type { DiscordConfig, Notification } from '../types.ts'; + +/** + * Discord embed color constants + */ +const COLORS = { + SUCCESS: 0x00ff00, // Green + FAILED: 0xff0000, // Red + ERROR: 0xff0000, // Red + INFO: 0x0099ff, // Blue + WARNING: 0xffaa00 // Orange +} as const; + +/** + * Discord notification service implementation + */ +export class DiscordNotifier extends BaseHttpNotifier { + constructor(private config: DiscordConfig) { + super(); + } + + getName(): string { + return 'Discord'; + } + + protected getWebhookUrl(): string { + return this.config.webhook_url; + } + + protected formatPayload(notification: Notification) { + const color = this.getColorForType(notification.type); + + return { + username: this.config.username || 'Profilarr', + avatar_url: this.config.avatar_url, + content: this.config.enable_mentions ? '@here' : undefined, + embeds: [ + { + title: notification.title, + description: notification.message, + color, + timestamp: new Date().toISOString(), + footer: { + text: `Type: ${notification.type}` + } + } + ] + }; + } + + /** + * Determine embed color based on notification type + */ + private getColorForType(type: string): number { + const lowerType = type.toLowerCase(); + + if (lowerType.includes('success')) { + return COLORS.SUCCESS; + } + if (lowerType.includes('failed') || lowerType.includes('error')) { + return COLORS.ERROR; + } + if (lowerType.includes('warning') || lowerType.includes('warn')) { + return COLORS.WARNING; + } + + return COLORS.INFO; + } +} diff --git a/src/notifications/notifiers/index.ts b/src/notifications/notifiers/index.ts new file mode 100644 index 0000000..bd45e4b --- /dev/null +++ b/src/notifications/notifiers/index.ts @@ -0,0 +1,5 @@ +/** + * Export all notification service implementations + */ + +export { DiscordNotifier } from './DiscordNotifier.ts'; \ No newline at end of file diff --git a/src/notifications/types.ts b/src/notifications/types.ts new file mode 100644 index 0000000..08464f8 --- /dev/null +++ b/src/notifications/types.ts @@ -0,0 +1,40 @@ +/** + * Core notification types and interfaces + */ + +/** + * Notification payload sent to services + */ +export interface Notification { + type: string; // e.g., 'job.backup.success', 'job.cleanup.failed' + title: string; + message: string; + metadata?: Record; +} + +/** + * Result of a notification attempt + */ +export interface NotificationResult { + success: boolean; + error?: string; +} + +/** + * Configuration for Discord notifications + */ +export interface DiscordConfig { + webhook_url: string; + username?: string; + avatar_url?: string; + enable_mentions?: boolean; +} +/** + * Union type for all notification service configs + */ +export type NotificationServiceConfig = DiscordConfig; + +/** + * Service types supported + */ +export type NotificationServiceType = 'discord'; \ No newline at end of file diff --git a/src/routes/settings/notifications/+page.server.ts b/src/routes/settings/notifications/+page.server.ts new file mode 100644 index 0000000..07957d4 --- /dev/null +++ b/src/routes/settings/notifications/+page.server.ts @@ -0,0 +1,145 @@ +import type { Actions, RequestEvent } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; +import { logger } from '$logger'; +import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; +import { notificationHistoryQueries } from '$db/queries/notificationHistory.ts'; +import type { NotificationService } from '$db/queries/notificationServices.ts'; + +interface NotificationServiceWithStats extends NotificationService { + successCount: number; + failedCount: number; + successRate: number; +} + +export const load = () => { + const services = notificationServicesQueries.getAll(); + + // Get stats for each service + const servicesWithStats: NotificationServiceWithStats[] = services.map((service) => { + const stats = notificationHistoryQueries.getStats(service.id); + return { + ...service, + successCount: stats.success, + failedCount: stats.failed, + successRate: stats.successRate + }; + }); + + // Get recent notification history (last 50) + const history = notificationHistoryQueries.getRecent(50); + + return { + services: servicesWithStats, + history + }; +}; + +export const actions: Actions = { + toggleEnabled: async ({ request }: RequestEvent) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + const enabled = formData.get('enabled') === 'true'; + + if (!id) { + return fail(400, { error: 'Service ID is required' }); + } + + try { + const success = notificationServicesQueries.update(id, { enabled }); + + if (!success) { + return fail(400, { error: 'Failed to update service' }); + } + + await logger.info(`Notification service ${enabled ? 'enabled' : 'disabled'}`, { + source: 'settings/notifications', + meta: { serviceId: id, enabled } + }); + + return { success: true }; + } catch (err) { + await logger.error('Failed to toggle notification service', { + source: 'settings/notifications', + meta: { serviceId: id, error: err } + }); + return fail(500, { error: 'Failed to update service' }); + } + }, + + delete: async ({ request }: RequestEvent) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + if (!id) { + return fail(400, { error: 'Service ID is required' }); + } + + try { + const success = notificationServicesQueries.delete(id); + + if (!success) { + return fail(400, { error: 'Failed to delete service' }); + } + + await logger.info('Notification service deleted', { + source: 'settings/notifications', + meta: { serviceId: id } + }); + + return { success: true }; + } catch (err) { + await logger.error('Failed to delete notification service', { + source: 'settings/notifications', + meta: { serviceId: id, error: err } + }); + return fail(500, { error: 'Failed to delete service' }); + } + }, + + testNotification: async ({ request }: RequestEvent) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + if (!id) { + return fail(400, { error: 'Service ID is required' }); + } + + try { + const service = notificationServicesQueries.getById(id); + + if (!service) { + return fail(404, { error: 'Service not found' }); + } + + // Send test notification directly (bypass enabled_types filter) + const { DiscordNotifier } = await import('$notifications/notifiers/DiscordNotifier.ts'); + const config = JSON.parse(service.config); + + let notifier; + if (service.service_type === 'discord') { + notifier = new DiscordNotifier(config); + } else { + return fail(400, { error: 'Unknown service type' }); + } + + await notifier.notify({ + type: 'test', + title: 'Test Notification', + message: 'This is a test notification from Profilarr. If you received this, your notification service is working correctly!', + metadata: { + serviceId: id, + serviceName: service.name, + timestamp: new Date().toISOString() + } + }); + + return { success: true }; + } catch (err) { + await logger.error('Failed to send test notification', { + source: 'settings/notifications', + meta: { serviceId: id, error: err } + }); + return fail(500, { error: 'Failed to send test notification' }); + } + } +}; diff --git a/src/routes/settings/notifications/+page.svelte b/src/routes/settings/notifications/+page.svelte new file mode 100644 index 0000000..87399a5 --- /dev/null +++ b/src/routes/settings/notifications/+page.svelte @@ -0,0 +1,380 @@ + + + + +
+ +
+
+
+

Notifications

+

+ Manage notification services and delivery settings +

+
+ + + + + Add Service + +
+
+ + +
+ +
+
+ +

+ Notification Services +

+
+
+ + +
+ + + + + + + + + + + + + {#each data.services as service (service.id)} + + + + + + + + + + + + + + + + + + + + {:else} + + + + {/each} + +
+ Service + + Type + + Status + + Enabled Types + + Statistics + + Actions +
+ {service.name} + +
+ {#if service.service_type === 'discord'} + + + + {:else} + + {/if} + {getServiceTypeName(service.service_type)} +
+
+
{ + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + toastStore.add( + 'error', + (result.data as { error?: string }).error || 'Failed to update service' + ); + } else if (result.type === 'success') { + toastStore.add('success', 'Service updated successfully'); + } + await update(); + }; + }} + > + + + +
+
+
+ {#each getEnabledTypes(service.enabled_types) as type} + + {formatNotificationType(type)} + + {:else} + None + {/each} +
+
+ {#if service.successCount + service.failedCount > 0} +
+ {service.successCount} sent + • + {service.failedCount} failed + • + {formatSuccessRate(service.successRate)} +
+ {:else} + No notifications sent + {/if} +
+
+ +
{ + testingServiceId = service.id; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + toastStore.add( + 'error', + (result.data as { error?: string }).error || 'Failed to send test notification' + ); + } else if (result.type === 'success') { + toastStore.add('success', 'Test notification sent successfully'); + } + testingServiceId = null; + await update(); + }; + }} + > + + +
+ + + + + + + +
{ + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + toastStore.add( + 'error', + (result.data as { error?: string }).error || 'Failed to delete service' + ); + } else if (result.type === 'success') { + toastStore.add('success', 'Service deleted successfully'); + } + await update(); + }; + }} + > + + +
+
+
+ No notification services configured. Click "Add Service" to get started. +
+
+
+ + +
+ +
+
+ + + diff --git a/src/routes/settings/notifications/edit/[id]/+page.server.ts b/src/routes/settings/notifications/edit/[id]/+page.server.ts new file mode 100644 index 0000000..fa9fb49 --- /dev/null +++ b/src/routes/settings/notifications/edit/[id]/+page.server.ts @@ -0,0 +1,123 @@ +import type { Actions, RequestEvent } from '@sveltejs/kit'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logger } from '$logger'; +import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; + +export const load = ({ params }: { params: { id: string } }) => { + const { id } = params; + + if (!id) { + throw error(400, 'Service ID is required'); + } + + const service = notificationServicesQueries.getById(id); + + if (!service) { + throw error(404, 'Notification service not found'); + } + + // Parse JSON fields for the component + const config = JSON.parse(service.config); + const enabledTypes = JSON.parse(service.enabled_types); + + // Remove webhook_url from config (security - don't expose secrets) + const { webhook_url: _webhook_url, ...configWithoutWebhook } = config; + + return { + service: { + id: service.id, + name: service.name, + serviceType: service.service_type, + enabled: service.enabled === 1, + config: configWithoutWebhook, + enabledTypes + } + }; +}; + +export const actions: Actions = { + edit: async ({ request, params }: RequestEvent) => { + const { id } = params; + + if (!id) { + return fail(400, { error: 'Service ID is required' }); + } + + const service = notificationServicesQueries.getById(id); + if (!service) { + return fail(404, { error: 'Service not found' }); + } + + const formData = await request.formData(); + const name = formData.get('name') as string; + + if (!name) { + return fail(400, { error: 'Service name is required' }); + } + + // Validate name uniqueness (excluding current service) + const existingService = notificationServicesQueries.getAll().find( + (s) => s.name === name && s.id !== id + ); + if (existingService) { + return fail(400, { error: 'A service with this name already exists' }); + } + + // Build config based on service type (service type cannot be changed) + let config: Record = {}; + let enabledTypes: string[] = []; + + if (service.service_type === 'discord') { + const webhookUrl = formData.get('webhook_url') as string; + const username = formData.get('username') as string; + const avatarUrl = formData.get('avatar_url') as string; + const enableMentions = formData.get('enable_mentions') === 'on'; + + // Parse existing config to preserve webhook if not provided + const existingConfig = JSON.parse(service.config); + + config = { + // Keep existing webhook_url if new one not provided + webhook_url: webhookUrl || existingConfig.webhook_url, + ...(username && { username }), + ...(avatarUrl && { avatar_url: avatarUrl }), + enable_mentions: enableMentions + }; + + // Get enabled notification types + const jobBackupSuccess = formData.get('job.create_backup.success') === 'on'; + const jobBackupFailed = formData.get('job.create_backup.failed') === 'on'; + const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on'; + const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on'; + const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on'; + const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on'; + + enabledTypes = [ + ...(jobBackupSuccess ? ['job.create_backup.success'] : []), + ...(jobBackupFailed ? ['job.create_backup.failed'] : []), + ...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []), + ...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []), + ...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []), + ...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : []) + ]; + } + + // Update the service + const success = notificationServicesQueries.update(id, { + name, + config, + enabledTypes + }); + + if (!success) { + return fail(500, { error: 'Failed to update notification service' }); + } + + await logger.info('Notification service updated', { + source: 'settings/notifications/edit', + meta: { serviceId: id, serviceType: service.service_type, name } + }); + + throw redirect(303, '/settings/notifications'); + } +}; diff --git a/src/routes/settings/notifications/edit/[id]/+page.svelte b/src/routes/settings/notifications/edit/[id]/+page.svelte new file mode 100644 index 0000000..d78fed1 --- /dev/null +++ b/src/routes/settings/notifications/edit/[id]/+page.svelte @@ -0,0 +1,40 @@ + + +
+ +
+ + + Back to Notifications + + +
+

+ Edit Notification Service +

+

+ Update notification service configuration +

+
+
+ + + +
diff --git a/src/routes/settings/notifications/new/+page.server.ts b/src/routes/settings/notifications/new/+page.server.ts new file mode 100644 index 0000000..7752f61 --- /dev/null +++ b/src/routes/settings/notifications/new/+page.server.ts @@ -0,0 +1,84 @@ +import type { Actions, RequestEvent } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; +import { logger } from '$logger'; +import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; + +export const actions: Actions = { + create: async ({ request }: RequestEvent) => { + const formData = await request.formData(); + const serviceType = formData.get('type') as string; + const name = formData.get('name') as string; + + if (!serviceType || !name) { + return fail(400, { error: 'Service type and name are required' }); + } + + // Validate name uniqueness + if (notificationServicesQueries.existsByName(name)) { + return fail(400, { error: 'A service with this name already exists' }); + } + + // Build config based on service type + let config: Record = {}; + let enabledTypes: string[] = []; + + if (serviceType === 'discord') { + const webhookUrl = formData.get('webhook_url') as string; + const username = formData.get('username') as string; + const avatarUrl = formData.get('avatar_url') as string; + const enableMentions = formData.get('enable_mentions') === 'on'; + + if (!webhookUrl) { + return fail(400, { error: 'Webhook URL is required for Discord' }); + } + + config = { + webhook_url: webhookUrl, + ...(username && { username }), + ...(avatarUrl && { avatar_url: avatarUrl }), + enable_mentions: enableMentions + }; + + // Get enabled notification types + const jobBackupSuccess = formData.get('job.create_backup.success') === 'on'; + const jobBackupFailed = formData.get('job.create_backup.failed') === 'on'; + const jobCleanupBackupsSuccess = formData.get('job.cleanup_backups.success') === 'on'; + const jobCleanupBackupsFailed = formData.get('job.cleanup_backups.failed') === 'on'; + const jobCleanupLogsSuccess = formData.get('job.cleanup_logs.success') === 'on'; + const jobCleanupLogsFailed = formData.get('job.cleanup_logs.failed') === 'on'; + + enabledTypes = [ + ...(jobBackupSuccess ? ['job.create_backup.success'] : []), + ...(jobBackupFailed ? ['job.create_backup.failed'] : []), + ...(jobCleanupBackupsSuccess ? ['job.cleanup_backups.success'] : []), + ...(jobCleanupBackupsFailed ? ['job.cleanup_backups.failed'] : []), + ...(jobCleanupLogsSuccess ? ['job.cleanup_logs.success'] : []), + ...(jobCleanupLogsFailed ? ['job.cleanup_logs.failed'] : []) + ]; + } + + // Generate UUID for the service + const id = crypto.randomUUID(); + + // Create the service + const success = notificationServicesQueries.create({ + id, + name, + serviceType, + enabled: true, + config, + enabledTypes + }); + + if (!success) { + return fail(500, { error: 'Failed to create notification service' }); + } + + await logger.info('Notification service created', { + source: 'settings/notifications/new', + meta: { serviceId: id, serviceType, name } + }); + + throw redirect(303, '/settings/notifications'); + } +}; diff --git a/src/routes/settings/notifications/new/+page.svelte b/src/routes/settings/notifications/new/+page.svelte new file mode 100644 index 0000000..df9857f --- /dev/null +++ b/src/routes/settings/notifications/new/+page.svelte @@ -0,0 +1,29 @@ + + +
+ +
+ + + Back to Notifications + + +
+

+ Add Notification Service +

+

+ Configure a new notification service +

+
+
+ + + +
diff --git a/svelte.config.js b/svelte.config.js index cf58146..e1f8c1c 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -21,7 +21,8 @@ const config = { $arr: './src/utils/arr', $http: './src/utils/http', $api: './src/utils/api/request.ts', - $utils: './src/utils' + $utils: './src/utils', + $notifications: './src/notifications', } } };