mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-23 03:11:01 +01:00
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:
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
109
src/components/notifications/DiscordConfiguration.svelte
Normal file
109
src/components/notifications/DiscordConfiguration.svelte
Normal 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>
|
||||
137
src/components/notifications/NotificationHistory.svelte
Normal file
137
src/components/notifications/NotificationHistory.svelte
Normal 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>
|
||||
200
src/components/notifications/NotificationServiceForm.svelte
Normal file
200
src/components/notifications/NotificationServiceForm.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
79
src/db/migrations/007_create_notification_tables.ts
Normal file
79
src/db/migrations/007_create_notification_tables.ts
Normal 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;
|
||||
`
|
||||
};
|
||||
197
src/db/queries/notificationHistory.ts
Normal file
197
src/db/queries/notificationHistory.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
167
src/db/queries/notificationServices.ts
Normal file
167
src/db/queries/notificationServices.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
src/notifications/NotificationManager.ts
Normal file
159
src/notifications/NotificationManager.ts
Normal 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();
|
||||
99
src/notifications/base/BaseHttpNotifier.ts
Normal file
99
src/notifications/base/BaseHttpNotifier.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/notifications/base/Notifier.ts
Normal file
17
src/notifications/base/Notifier.ts
Normal 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;
|
||||
}
|
||||
70
src/notifications/notifiers/DiscordNotifier.ts
Normal file
70
src/notifications/notifiers/DiscordNotifier.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/notifications/notifiers/index.ts
Normal file
5
src/notifications/notifiers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export all notification service implementations
|
||||
*/
|
||||
|
||||
export { DiscordNotifier } from './DiscordNotifier.ts';
|
||||
40
src/notifications/types.ts
Normal file
40
src/notifications/types.ts
Normal 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';
|
||||
145
src/routes/settings/notifications/+page.server.ts
Normal file
145
src/routes/settings/notifications/+page.server.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
380
src/routes/settings/notifications/+page.svelte
Normal file
380
src/routes/settings/notifications/+page.svelte
Normal 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}
|
||||
/>
|
||||
123
src/routes/settings/notifications/edit/[id]/+page.server.ts
Normal file
123
src/routes/settings/notifications/edit/[id]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
40
src/routes/settings/notifications/edit/[id]/+page.svelte
Normal file
40
src/routes/settings/notifications/edit/[id]/+page.svelte
Normal 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>
|
||||
84
src/routes/settings/notifications/new/+page.server.ts
Normal file
84
src/routes/settings/notifications/new/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
29
src/routes/settings/notifications/new/+page.svelte
Normal file
29
src/routes/settings/notifications/new/+page.svelte
Normal 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>
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user