feat(notifications): add notification module with Discord webhook support

- Database schema for notification services and history tracking
  - Notifier interface with Discord webhook implementation
  - UI for creating/editing/managing notification services
  - Integration with job completion events
  - Service-level enable/disable and notification type filtering
  - Test notifications and notification history view
This commit is contained in:
Sam Chau
2025-10-22 04:07:03 +10:30
parent 5cd1bf82ff
commit c83217a72a
24 changed files with 2173 additions and 5 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -0,0 +1,109 @@
<script lang="ts">
export let config: Record<string, unknown> = {};
export let mode: 'create' | 'edit' = 'create';
// Extract config values with defaults
let webhookUrl = (config.webhook_url as string) || '';
let username = (config.username as string) || '';
let avatarUrl = (config.avatar_url as string) || '';
let enableMentions = (config.enable_mentions as boolean) || false;
</script>
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Discord Configuration
</h2>
<div class="space-y-4">
<!-- Webhook URL -->
<div>
<label
for="webhook_url"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Webhook URL
{#if mode === 'create'}
<span class="text-red-500">*</span>
{/if}
</label>
<input
type="url"
id="webhook_url"
name="webhook_url"
required={mode === 'create'}
placeholder="https://discord.com/api/webhooks/..."
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{#if mode === 'edit'}
Leave blank to keep existing webhook URL
{:else}
Get this from Server Settings → Integrations → Webhooks in Discord
{/if}
</p>
</div>
<!-- Bot Username (Optional) -->
<div>
<label
for="username"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Bot Username (Optional)
</label>
<input
type="text"
id="username"
name="username"
placeholder="Profilarr"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Custom username for the webhook bot
</p>
</div>
<!-- Avatar URL (Optional) -->
<div>
<label
for="avatar_url"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Avatar URL (Optional)
</label>
<input
type="url"
id="avatar_url"
name="avatar_url"
placeholder="https://example.com/avatar.png"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Custom avatar image for the webhook bot
</p>
</div>
<!-- Enable Mentions -->
<div class="flex items-start gap-3">
<input
type="checkbox"
id="enable_mentions"
name="enable_mentions"
class="mt-1 h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800"
/>
<div class="flex-1">
<label
for="enable_mentions"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Enable @here mentions
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Mention @here in notifications to alert online users
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { Bell } from 'lucide-svelte';
import type { NotificationHistoryRecord } from '$db/queries/notificationHistory.ts';
export let history: NotificationHistoryRecord[];
export let services: Array<{ id: string; name: string }>;
function formatDateTime(date: string): string {
return new Date(date).toLocaleString();
}
function getServiceName(serviceId: string): string {
const service = services.find((s) => s.id === serviceId);
return service?.name || 'Unknown';
}
function formatNotificationType(type: string): string {
// Convert 'job.create_backup.success' to 'Backup Success'
const parts = type.split('.');
if (parts.length >= 3) {
const action = parts[1].replace(/_/g, ' ');
const status = parts[2];
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${status.charAt(0).toUpperCase() + status.slice(1)}`;
}
return type;
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
<Bell size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Recent Notifications
</h2>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Time
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Service
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Type
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Title
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Status
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each history as record (record.id)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<!-- Time -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{formatDateTime(record.sent_at)}
</td>
<!-- Service -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{getServiceName(record.service_id)}
</td>
<!-- Type -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<span
class="inline-flex items-center rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200"
>
{formatNotificationType(record.notification_type)}
</span>
</td>
<!-- Title -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{record.title}
</td>
<!-- Status -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
{#if record.status === 'success'}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200"
>
Success
</span>
{:else}
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200"
title={record.error || 'Unknown error'}
>
Failed
</span>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No notification history available yet.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import DiscordConfiguration from './DiscordConfiguration.svelte';
import { siDiscord } from 'simple-icons';
export let mode: 'create' | 'edit' = 'create';
export let initialData: {
name?: string;
serviceType?: string;
config?: Record<string, unknown>;
enabledTypes?: string[];
} = {};
let selectedType: 'discord' | 'slack' | 'email' = (initialData.serviceType as 'discord') || 'discord';
let serviceName = initialData.name || '';
// Available notification types
const notificationTypes = [
{ id: 'job.create_backup.success', label: 'Backup Created (Success)', category: 'Backups' },
{ id: 'job.create_backup.failed', label: 'Backup Created (Failed)', category: 'Backups' },
{
id: 'job.cleanup_backups.success',
label: 'Backup Cleanup (Success)',
category: 'Backups'
},
{ id: 'job.cleanup_backups.failed', label: 'Backup Cleanup (Failed)', category: 'Backups' },
{ id: 'job.cleanup_logs.success', label: 'Log Cleanup (Success)', category: 'Logs' },
{ id: 'job.cleanup_logs.failed', label: 'Log Cleanup (Failed)', category: 'Logs' }
];
// Group notification types by category
const groupedTypes = notificationTypes.reduce(
(acc, type) => {
if (!acc[type.category]) {
acc[type.category] = [];
}
acc[type.category].push(type);
return acc;
},
{} as Record<string, typeof notificationTypes>
);
// Check if a notification type should be checked by default
function isTypeEnabled(typeId: string): boolean {
return initialData.enabledTypes?.includes(typeId) || false;
}
</script>
<form
method="POST"
action="?/{mode}"
use:enhance={() => {
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 -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Basic Settings
</h2>
<div class="space-y-4">
<!-- Service Type -->
<div>
<label
for="type"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Service Type
<span class="text-red-500">*</span>
</label>
<div class="relative mt-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{#if selectedType === 'discord'}
<svg
role="img"
viewBox="0 0 24 24"
class="h-4 w-4 text-neutral-600 dark:text-neutral-400"
fill="currentColor"
>
<path d={siDiscord.path} />
</svg>
{/if}
</div>
<select
id="type"
name="type"
bind:value={selectedType}
required
disabled={mode === 'edit'}
class="block w-full rounded-lg border border-neutral-300 bg-white py-2 pl-10 pr-3 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option value="discord">Discord</option>
</select>
</div>
{#if mode === 'edit'}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Service type cannot be changed after creation
</p>
{/if}
</div>
<!-- Service Name -->
<div>
<label
for="name"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Service Name
<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={serviceName}
required
placeholder="e.g., Main Discord Server"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
A friendly name to identify this notification service
</p>
</div>
</div>
</div>
<!-- Service Configuration -->
{#if selectedType === 'discord'}
<DiscordConfiguration config={initialData.config} mode={mode} />
{/if}
<!-- Notification Types -->
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Notification Types
</h2>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
Select which types of notifications should be sent to this service
</p>
<div class="space-y-4">
{#each Object.entries(groupedTypes) as [category, types]}
<div>
<h3 class="mb-2 text-sm font-semibold text-neutral-700 dark:text-neutral-300">
{category}
</h3>
<div class="space-y-2">
{#each types as type}
<div class="flex items-start gap-3">
<input
type="checkbox"
id={type.id}
name={type.id}
checked={isTypeEnabled(type.id)}
class="mt-0.5 h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800"
/>
<label for={type.id} class="text-sm text-neutral-700 dark:text-neutral-300">
{type.label}
</label>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<a
href="/settings/notifications"
class="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel
</a>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{mode === 'create' ? 'Create Service' : 'Update Service'}
</button>
</div>
</form>

View File

@@ -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

View File

@@ -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;
`
};

View File

@@ -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<string, unknown>;
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<NotificationHistoryRecord>(
`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<NotificationHistoryRecord>(
'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<NotificationHistoryRecord>(
'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<NotificationHistoryRecord>(
'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<NotificationHistoryRecord>(
"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;
}
};

View File

@@ -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<NotificationService>('SELECT * FROM notification_services ORDER BY created_at DESC');
},
/**
* Get all enabled notification services
*/
getAllEnabled(): NotificationService[] {
return db.query<NotificationService>(
'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<NotificationService>(
'SELECT * FROM notification_services WHERE id = ?',
id
);
},
/**
* Get notification services by type
*/
getByType(serviceType: string): NotificationService[] {
return db.query<NotificationService>(
'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;
}
};

View File

@@ -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);

View File

@@ -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<boolean> {
});
}
// 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,

View File

@@ -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<void> {
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<void> {
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();

View File

@@ -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<void> {
// 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)
}
});
}
}
}

View File

@@ -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<void>;
/**
* Get the service name for logging purposes
*/
getName(): string;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Export all notification service implementations
*/
export { DiscordNotifier } from './DiscordNotifier.ts';

View File

@@ -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<string, unknown>;
}
/**
* 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';

View File

@@ -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' });
}
}
};

View File

@@ -0,0 +1,380 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { Plus, Trash2, Bell, BellOff, MessageSquare, Send, Loader2, Pencil } from 'lucide-svelte';
import Modal from '$components/modal/Modal.svelte';
import NotificationHistory from '$components/notifications/NotificationHistory.svelte';
import { siDiscord } from 'simple-icons';
import type { PageData } from './$types';
export let data: PageData;
// Modal state
let showDeleteModal = false;
let selectedService: string | null = null;
let selectedServiceName: string | null = null;
let deleteFormRef: HTMLFormElement | null = null;
// Test notification loading state
let testingServiceId: string | null = null;
function openDeleteModal(id: string, name: string, formRef: HTMLFormElement) {
selectedService = id;
selectedServiceName = name;
deleteFormRef = formRef;
showDeleteModal = true;
}
function confirmDelete() {
if (deleteFormRef) {
deleteFormRef.requestSubmit();
}
showDeleteModal = false;
selectedService = null;
selectedServiceName = null;
deleteFormRef = null;
}
function cancelDelete() {
showDeleteModal = false;
selectedService = null;
selectedServiceName = null;
deleteFormRef = null;
}
function getServiceIcon(serviceType: string) {
switch (serviceType) {
case 'discord':
return MessageSquare;
default:
return Bell;
}
}
function getServiceTypeName(serviceType: string): string {
switch (serviceType) {
case 'discord':
return 'Discord';
case 'slack':
return 'Slack';
case 'email':
return 'Email';
default:
return serviceType;
}
}
function formatSuccessRate(rate: number): string {
return rate.toFixed(1) + '%';
}
function formatNotificationType(type: string): string {
// Convert 'job.create_backup.success' to 'Backup Success'
const parts = type.split('.');
if (parts.length >= 3) {
const action = parts[1].replace(/_/g, ' ');
const status = parts[2];
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${status.charAt(0).toUpperCase() + status.slice(1)}`;
}
return type;
}
function getEnabledTypes(enabledTypesJson: string): string[] {
try {
return JSON.parse(enabledTypesJson);
} catch {
return [];
}
}
</script>
<div class="p-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Notifications</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
Manage notification services and delivery settings
</p>
</div>
<!-- Add Service Button -->
<a
href="/settings/notifications/new"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Plus size={16} />
Add Service
</a>
</div>
</div>
<!-- Services List -->
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
<Bell size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Notification Services
</h2>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Service
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Type
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Status
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Enabled Types
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Statistics
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Actions
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each data.services as service (service.id)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<!-- Service Name -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
<span class="font-medium">{service.name}</span>
</td>
<!-- Type -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<div class="flex items-center gap-2">
{#if service.service_type === 'discord'}
<svg
role="img"
viewBox="0 0 24 24"
class="h-4 w-4 text-neutral-600 dark:text-neutral-400"
fill="currentColor"
>
<path d={siDiscord.path} />
</svg>
{:else}
<svelte:component
this={getServiceIcon(service.service_type)}
size={16}
class="text-neutral-600 dark:text-neutral-400"
/>
{/if}
<span>{getServiceTypeName(service.service_type)}</span>
</div>
</td>
<!-- Status -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<form
method="POST"
action="?/toggleEnabled"
use:enhance={() => {
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();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<input type="hidden" name="enabled" value={service.enabled ? 'false' : 'true'} />
<button
type="submit"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors {service.enabled
? 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200'
: 'bg-neutral-100 text-neutral-800 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-200'}"
>
<svelte:component
this={service.enabled ? Bell : BellOff}
size={12}
/>
{service.enabled ? 'Enabled' : 'Disabled'}
</button>
</form>
</td>
<!-- Enabled Types -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<div class="flex max-w-sm flex-wrap gap-1">
{#each getEnabledTypes(service.enabled_types) as type}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{formatNotificationType(type)}
</span>
{:else}
<span class="text-xs text-neutral-400 dark:text-neutral-500">None</span>
{/each}
</div>
</td>
<!-- Statistics -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{#if service.successCount + service.failedCount > 0}
<div class="text-xs">
<span class="text-green-600 dark:text-green-400">{service.successCount} sent</span>
<span class="text-red-600 dark:text-red-400">{service.failedCount} failed</span>
<span>{formatSuccessRate(service.successRate)}</span>
</div>
{:else}
<span class="text-neutral-400 dark:text-neutral-500">No notifications sent</span>
{/if}
</td>
<!-- Actions -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<div class="flex items-center gap-2">
<!-- Test Button -->
<form
method="POST"
action="?/testNotification"
use:enhance={() => {
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();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<button
type="submit"
disabled={testingServiceId === service.id}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-blue-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-blue-400 dark:hover:bg-neutral-700"
title="Send test notification"
>
{#if testingServiceId === service.id}
<Loader2 size={14} class="animate-spin" />
{:else}
<Send size={14} />
{/if}
</button>
</form>
<!-- Edit Button -->
<a
href="/settings/notifications/edit/{service.id}"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit service"
>
<Pencil size={14} />
</a>
<!-- Delete Button -->
<form
method="POST"
action="?/delete"
use:enhance={() => {
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();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<button
type="button"
on:click={(e) => {
const form = e.currentTarget.closest('form');
if (form) openDeleteModal(service.id, service.name, form);
}}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
title="Delete service"
>
<Trash2 size={14} />
</button>
</form>
</div>
</td>
</tr>
{:else}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No notification services configured. Click "Add Service" to get started.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Notification History Component -->
<div class="mt-8">
<NotificationHistory history={data.history} services={data.services} />
</div>
</div>
<!-- Delete Confirmation Modal -->
<Modal
open={showDeleteModal}
header="Delete Service"
bodyMessage="Are you sure you want to delete this notification service? This action cannot be undone.{selectedServiceName
? `\n\nService: ${selectedServiceName}`
: ''}"
confirmText="Delete Service"
cancelText="Cancel"
confirmDanger={true}
on:confirm={confirmDelete}
on:cancel={cancelDelete}
/>

View File

@@ -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<string, unknown> = {};
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');
}
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { ArrowLeft } from 'lucide-svelte';
import NotificationServiceForm from '$components/notifications/NotificationServiceForm.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<div class="p-8">
<!-- Header -->
<div class="mb-8">
<a
href="/settings/notifications"
class="mb-4 inline-flex items-center gap-2 text-sm text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
>
<ArrowLeft size={16} />
Back to Notifications
</a>
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">
Edit Notification Service
</h1>
<p class="mt-2 text-lg text-neutral-600 dark:text-neutral-400">
Update notification service configuration
</p>
</div>
</div>
<!-- Form -->
<NotificationServiceForm
mode="edit"
initialData={{
name: data.service.name,
serviceType: data.service.serviceType,
config: data.service.config,
enabledTypes: data.service.enabledTypes
}}
/>
</div>

View File

@@ -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<string, unknown> = {};
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');
}
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { ArrowLeft } from 'lucide-svelte';
import NotificationServiceForm from '$components/notifications/NotificationServiceForm.svelte';
</script>
<div class="p-8">
<!-- Header -->
<div class="mb-8">
<a
href="/settings/notifications"
class="mb-4 inline-flex items-center gap-2 text-sm text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
>
<ArrowLeft size={16} />
Back to Notifications
</a>
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">
Add Notification Service
</h1>
<p class="mt-2 text-lg text-neutral-600 dark:text-neutral-400">
Configure a new notification service
</p>
</div>
</div>
<!-- Form -->
<NotificationServiceForm mode="create" />
</div>

View File

@@ -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',
}
}
};