From 37ae5164e60b89e41cd4bf09ed014d646fcf325f Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Tue, 4 Nov 2025 06:58:54 +1030 Subject: [PATCH] feat(pcd): add database linking functionality --- deno.json | 1 + src/lib/client/ui/table/Table.svelte | 126 +++++++ src/lib/server/db/migrations.ts | 8 +- .../008_create_database_instances.ts | 62 ++++ .../009_add_personal_access_token.ts | 47 +++ .../db/migrations/010_add_is_private.ts | 48 +++ .../server/db/queries/databaseInstances.ts | 211 +++++++++++ src/lib/server/db/schema.sql | 39 +- .../server/jobs/definitions/syncDatabases.ts | 60 ++++ src/lib/server/jobs/init.ts | 2 + src/lib/server/jobs/logic/createBackup.ts | 2 +- src/lib/server/jobs/logic/syncDatabases.ts | 139 ++++++++ src/lib/server/pcd/deps.ts | 106 ++++++ src/lib/server/pcd/manifest.ts | 152 ++++++++ src/lib/server/pcd/paths.ts | 19 + src/lib/server/pcd/pcd.ts | 242 +++++++++++++ src/lib/server/utils/config/config.ts | 4 + src/lib/server/utils/git/git.ts | 299 ++++++++++++++++ src/lib/server/utils/logger/logger.ts | 15 +- src/lib/shared/notificationTypes.ts | 118 ++++++ src/routes/databases/+page.server.ts | 54 +++ src/routes/databases/+page.svelte | 223 ++++++++++++ src/routes/databases/[id]/+page.server.ts | 21 ++ src/routes/databases/[id]/+page.svelte | 19 + .../databases/[id]/edit/+page.server.ts | 172 +++++++++ src/routes/databases/[id]/edit/+page.svelte | 9 + src/routes/databases/bruh/+page.server.ts | 21 ++ src/routes/databases/bruh/+page.svelte | 110 ++++++ .../databases/components/InstanceForm.svelte | 335 ++++++++++++++++++ src/routes/databases/new/+page.server.ts | 113 ++++++ src/routes/databases/new/+page.svelte | 9 + .../components/NotificationServiceForm.svelte | 26 +- .../notifications/edit/[id]/+page.server.ts | 20 +- .../notifications/new/+page.server.ts | 20 +- svelte.config.js | 1 + 35 files changed, 2790 insertions(+), 63 deletions(-) create mode 100644 src/lib/client/ui/table/Table.svelte create mode 100644 src/lib/server/db/migrations/008_create_database_instances.ts create mode 100644 src/lib/server/db/migrations/009_add_personal_access_token.ts create mode 100644 src/lib/server/db/migrations/010_add_is_private.ts create mode 100644 src/lib/server/db/queries/databaseInstances.ts create mode 100644 src/lib/server/jobs/definitions/syncDatabases.ts create mode 100644 src/lib/server/jobs/logic/syncDatabases.ts create mode 100644 src/lib/server/pcd/deps.ts create mode 100644 src/lib/server/pcd/manifest.ts create mode 100644 src/lib/server/pcd/paths.ts create mode 100644 src/lib/server/pcd/pcd.ts create mode 100644 src/lib/server/utils/git/git.ts create mode 100644 src/lib/shared/notificationTypes.ts create mode 100644 src/routes/databases/+page.server.ts create mode 100644 src/routes/databases/+page.svelte create mode 100644 src/routes/databases/[id]/+page.server.ts create mode 100644 src/routes/databases/[id]/+page.svelte create mode 100644 src/routes/databases/[id]/edit/+page.server.ts create mode 100644 src/routes/databases/[id]/edit/+page.svelte create mode 100644 src/routes/databases/bruh/+page.server.ts create mode 100644 src/routes/databases/bruh/+page.svelte create mode 100644 src/routes/databases/components/InstanceForm.svelte create mode 100644 src/routes/databases/new/+page.server.ts create mode 100644 src/routes/databases/new/+page.svelte diff --git a/deno.json b/deno.json index f3a7dfb..ae05bdb 100644 --- a/deno.json +++ b/deno.json @@ -10,6 +10,7 @@ "$server/": "./src/server/", "$db/": "./src/lib/server/db/", "$jobs/": "./src/lib/server/jobs/", + "$pcd/": "./src/lib/server/pcd/", "$arr/": "./src/lib/server/utils/arr/", "$http/": "./src/lib/server/utils/http/", "$utils/": "./src/lib/server/utils/", diff --git a/src/lib/client/ui/table/Table.svelte b/src/lib/client/ui/table/Table.svelte new file mode 100644 index 0000000..aede8e6 --- /dev/null +++ b/src/lib/client/ui/table/Table.svelte @@ -0,0 +1,126 @@ + + +
+ + + + + {#each columns as column} + + {/each} + + {#if $$slots.actions} + + {/if} + + + + + + {#if data.length === 0} + + + + {:else} + {#each data as row, rowIndex} + + {#each columns as column} + + {/each} + + + {#if $$slots.actions} + + {/if} + + {/each} + {/if} + +
+ {column.header} + + Actions +
+ {emptyMessage} +
+ {#if column.cell} + {@const rendered = column.cell(row)} + {#if typeof rendered === 'string'} + {rendered} + {:else} + + {/if} + {:else} + + {getCellValue(row, column.key)} + + {/if} + + +
+
diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 1b6b60c..be418bd 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -9,6 +9,9 @@ import { migration as migration004 } from './migrations/004_create_jobs_tables.t 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'; +import { migration as migration008 } from './migrations/008_create_database_instances.ts'; +import { migration as migration009 } from './migrations/009_add_personal_access_token.ts'; +import { migration as migration010 } from './migrations/010_add_is_private.ts'; export interface Migration { version: number; @@ -233,7 +236,10 @@ export function loadMigrations(): Migration[] { migration004, migration005, migration006, - migration007 + migration007, + migration008, + migration009, + migration010 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/008_create_database_instances.ts b/src/lib/server/db/migrations/008_create_database_instances.ts new file mode 100644 index 0000000..c5e044c --- /dev/null +++ b/src/lib/server/db/migrations/008_create_database_instances.ts @@ -0,0 +1,62 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 008: Create database_instances table + * + * Creates the table for storing linked Profilarr Compliant Database (PCD) repositories. + * These databases contain configuration profiles that can be synced to arr instances. + * + * Fields: + * - id: Auto-incrementing primary key + * - uuid: Unique identifier used for filesystem storage path + * - name: User-friendly name (unique) + * - repository_url: Git repository URL + * - local_path: Path where the repository is cloned (data/databases/{uuid}) + * - sync_strategy: 0 = manual check, >0 = auto-check every X minutes + * - auto_pull: 0 = notify only, 1 = auto-pull updates + * - enabled: Boolean flag (1=enabled, 0=disabled) + * - last_synced_at: Timestamp of last successful sync + * - created_at: Timestamp of creation + * - updated_at: Timestamp of last update + */ + +export const migration: Migration = { + version: 8, + name: 'Create database_instances table', + + up: ` + CREATE TABLE database_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Instance identification + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + + -- Repository connection + repository_url TEXT NOT NULL, + + -- Local storage + local_path TEXT NOT NULL, + + -- Sync settings + sync_strategy INTEGER NOT NULL DEFAULT 0, + auto_pull INTEGER NOT NULL DEFAULT 0, + + -- Status + enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Index for looking up by UUID + CREATE INDEX idx_database_instances_uuid ON database_instances(uuid); + `, + + down: ` + DROP INDEX IF EXISTS idx_database_instances_uuid; + DROP TABLE IF EXISTS database_instances; + ` +}; diff --git a/src/lib/server/db/migrations/009_add_personal_access_token.ts b/src/lib/server/db/migrations/009_add_personal_access_token.ts new file mode 100644 index 0000000..d14bfea --- /dev/null +++ b/src/lib/server/db/migrations/009_add_personal_access_token.ts @@ -0,0 +1,47 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 009: Add personal_access_token to database_instances + * + * Adds support for Personal Access Tokens (PAT) to enable: + * - Cloning private repositories + * - Push access for developers working on database content + */ + +export const migration: Migration = { + version: 9, + name: 'Add personal_access_token to database_instances', + + up: ` + ALTER TABLE database_instances + ADD COLUMN personal_access_token TEXT; + `, + + down: ` + -- SQLite doesn't support DROP COLUMN easily, so we recreate the table + CREATE TABLE database_instances_backup ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + repository_url TEXT NOT NULL, + local_path TEXT NOT NULL, + sync_strategy INTEGER NOT NULL DEFAULT 0, + auto_pull INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO database_instances_backup + SELECT id, uuid, name, repository_url, local_path, sync_strategy, + auto_pull, enabled, last_synced_at, created_at, updated_at + FROM database_instances; + + DROP TABLE database_instances; + + ALTER TABLE database_instances_backup RENAME TO database_instances; + + CREATE INDEX idx_database_instances_uuid ON database_instances(uuid); + ` +}; diff --git a/src/lib/server/db/migrations/010_add_is_private.ts b/src/lib/server/db/migrations/010_add_is_private.ts new file mode 100644 index 0000000..87a2592 --- /dev/null +++ b/src/lib/server/db/migrations/010_add_is_private.ts @@ -0,0 +1,48 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 010: Add is_private to database_instances + * + * Adds auto-detected flag to indicate if a repository is private. + * This is determined during the initial clone by attempting to access + * the repository with and without authentication. + */ + +export const migration: Migration = { + version: 10, + name: 'Add is_private to database_instances', + + up: ` + ALTER TABLE database_instances + ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0; + `, + + down: ` + -- SQLite doesn't support DROP COLUMN easily, so we recreate the table + CREATE TABLE database_instances_backup ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + repository_url TEXT NOT NULL, + local_path TEXT NOT NULL, + sync_strategy INTEGER NOT NULL DEFAULT 0, + auto_pull INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + personal_access_token TEXT, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO database_instances_backup + SELECT id, uuid, name, repository_url, local_path, sync_strategy, + auto_pull, enabled, personal_access_token, last_synced_at, created_at, updated_at + FROM database_instances; + + DROP TABLE database_instances; + + ALTER TABLE database_instances_backup RENAME TO database_instances; + + CREATE INDEX idx_database_instances_uuid ON database_instances(uuid); + ` +}; diff --git a/src/lib/server/db/queries/databaseInstances.ts b/src/lib/server/db/queries/databaseInstances.ts new file mode 100644 index 0000000..8fd9ae1 --- /dev/null +++ b/src/lib/server/db/queries/databaseInstances.ts @@ -0,0 +1,211 @@ +import { db } from '../db.ts'; + +/** + * Types for database_instances table + */ +export interface DatabaseInstance { + id: number; + uuid: string; + name: string; + repository_url: string; + local_path: string; + sync_strategy: number; + auto_pull: number; + enabled: number; + personal_access_token: string | null; + is_private: number; + last_synced_at: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateDatabaseInstanceInput { + uuid: string; + name: string; + repositoryUrl: string; + localPath: string; + syncStrategy?: number; + autoPull?: boolean; + enabled?: boolean; + personalAccessToken?: string; + isPrivate?: boolean; +} + +export interface UpdateDatabaseInstanceInput { + name?: string; + repositoryUrl?: string; + syncStrategy?: number; + autoPull?: boolean; + enabled?: boolean; + personalAccessToken?: string; +} + +/** + * All queries for database_instances table + */ +export const databaseInstancesQueries = { + /** + * Create a new database instance + */ + create(input: CreateDatabaseInstanceInput): number { + const syncStrategy = input.syncStrategy ?? 0; + const autoPull = input.autoPull !== false ? 1 : 0; + const enabled = input.enabled !== false ? 1 : 0; + const personalAccessToken = input.personalAccessToken || null; + const isPrivate = input.isPrivate ? 1 : 0; + + db.execute( + `INSERT INTO database_instances (uuid, name, repository_url, local_path, sync_strategy, auto_pull, enabled, personal_access_token, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + input.uuid, + input.name, + input.repositoryUrl, + input.localPath, + syncStrategy, + autoPull, + enabled, + personalAccessToken, + isPrivate + ); + + // Get the last inserted ID + const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id'); + return result?.id ?? 0; + }, + + /** + * Get a database instance by ID + */ + getById(id: number): DatabaseInstance | undefined { + return db.queryFirst('SELECT * FROM database_instances WHERE id = ?', id); + }, + + /** + * Get a database instance by UUID + */ + getByUuid(uuid: string): DatabaseInstance | undefined { + return db.queryFirst( + 'SELECT * FROM database_instances WHERE uuid = ?', + uuid + ); + }, + + /** + * Get all database instances + */ + getAll(): DatabaseInstance[] { + return db.query('SELECT * FROM database_instances ORDER BY name'); + }, + + /** + * Get enabled database instances + */ + getEnabled(): DatabaseInstance[] { + return db.query( + 'SELECT * FROM database_instances WHERE enabled = 1 ORDER BY name' + ); + }, + + /** + * Get databases that need auto-sync check + */ + getDueForSync(): DatabaseInstance[] { + return db.query( + `SELECT * FROM database_instances + WHERE enabled = 1 + AND sync_strategy > 0 + AND ( + last_synced_at IS NULL + OR datetime(last_synced_at, '+' || sync_strategy || ' minutes') <= datetime('now') + ) + ORDER BY last_synced_at ASC NULLS FIRST` + ); + }, + + /** + * Update a database instance + */ + update(id: number, input: UpdateDatabaseInstanceInput): boolean { + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (input.name !== undefined) { + updates.push('name = ?'); + params.push(input.name); + } + if (input.repositoryUrl !== undefined) { + updates.push('repository_url = ?'); + params.push(input.repositoryUrl); + } + if (input.syncStrategy !== undefined) { + updates.push('sync_strategy = ?'); + params.push(input.syncStrategy); + } + if (input.autoPull !== undefined) { + updates.push('auto_pull = ?'); + params.push(input.autoPull ? 1 : 0); + } + if (input.enabled !== undefined) { + updates.push('enabled = ?'); + params.push(input.enabled ? 1 : 0); + } + if (input.personalAccessToken !== undefined) { + updates.push('personal_access_token = ?'); + params.push(input.personalAccessToken || null); + } + + if (updates.length === 0) { + return false; + } + + // Add updated_at + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + const affected = db.execute( + `UPDATE database_instances SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Update last_synced_at timestamp + */ + updateSyncedAt(id: number): boolean { + const affected = db.execute( + 'UPDATE database_instances SET last_synced_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + id + ); + return affected > 0; + }, + + /** + * Delete a database instance + */ + delete(id: number): boolean { + const affected = db.execute('DELETE FROM database_instances WHERE id = ?', id); + return affected > 0; + }, + + /** + * Check if a database name already exists + */ + nameExists(name: string, excludeId?: number): boolean { + if (excludeId !== undefined) { + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM database_instances WHERE name = ? AND id != ?', + name, + excludeId + ); + return (result?.count ?? 0) > 0; + } + + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM database_instances WHERE name = ?', + name + ); + return (result?.count ?? 0) > 0; + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index cdc2080..8eaf853 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -1,7 +1,7 @@ -- Profilarr Database Schema -- This file documents the current database schema after all migrations -- DO NOT execute this file directly - use migrations instead --- Last updated: 2025-10-22 +-- Last updated: 2025-11-04 -- ============================================================================== -- TABLE: migrations @@ -191,6 +191,40 @@ CREATE TABLE notification_history ( FOREIGN KEY (service_id) REFERENCES notification_services(id) ON DELETE CASCADE ); +-- ============================================================================== +-- TABLE: database_instances +-- Purpose: Store linked Profilarr Compliant Database (PCD) repositories +-- Migration: 008_create_database_instances.ts, 009_add_personal_access_token.ts, 010_add_is_private.ts +-- ============================================================================== + +CREATE TABLE database_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Instance identification + uuid TEXT NOT NULL UNIQUE, -- UUID for filesystem storage path + name TEXT NOT NULL UNIQUE, -- User-friendly name (e.g., "Dictionarry DB") + + -- Repository connection + repository_url TEXT NOT NULL, -- Git repository URL + personal_access_token TEXT, -- PAT for private repos and push access (Migration 009) + is_private INTEGER NOT NULL DEFAULT 0, -- 1=private repo, 0=public (auto-detected, Migration 010) + + -- Local storage + local_path TEXT NOT NULL, -- Path where repo is cloned (data/databases/{uuid}) + + -- Sync settings + sync_strategy INTEGER NOT NULL DEFAULT 0, -- 0=manual check, >0=auto-check every X minutes + auto_pull INTEGER NOT NULL DEFAULT 0, -- 0=notify only, 1=auto-pull updates + + -- Status + enabled INTEGER NOT NULL DEFAULT 1, -- 1=enabled, 0=disabled + last_synced_at DATETIME, -- Timestamp of last successful sync + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + -- ============================================================================== -- INDEXES -- Purpose: Improve query performance @@ -212,3 +246,6 @@ CREATE INDEX idx_notification_services_type ON notification_services(service_typ 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); + +-- Database instances indexes (Migration: 008_create_database_instances.ts) +CREATE INDEX idx_database_instances_uuid ON database_instances(uuid); diff --git a/src/lib/server/jobs/definitions/syncDatabases.ts b/src/lib/server/jobs/definitions/syncDatabases.ts new file mode 100644 index 0000000..056b6bf --- /dev/null +++ b/src/lib/server/jobs/definitions/syncDatabases.ts @@ -0,0 +1,60 @@ +import { logger } from '$logger/logger.ts'; +import { syncDatabases } from '../logic/syncDatabases.ts'; +import type { JobDefinition, JobResult } from '../types.ts'; + +/** + * Sync PCD databases job + * Checks for databases that need syncing and pulls updates if auto_pull is enabled + */ +export const syncDatabasesJob: JobDefinition = { + name: 'sync_databases', + description: 'Auto-sync PCD databases with remote repositories', + schedule: '*/5 minutes', + + handler: async (): Promise => { + try { + await logger.info('Starting database sync job', { + source: 'SyncDatabasesJob' + }); + + // Run sync + const result = await syncDatabases(); + + // Log results for each database + for (const db of result.databases) { + if (db.success) { + if (db.updatesPulled > 0) { + await logger.info(`Synced database: ${db.name}`, { + source: 'SyncDatabasesJob', + meta: { databaseId: db.id, updatesPulled: db.updatesPulled } + }); + } + } else { + await logger.error(`Failed to sync database: ${db.name}`, { + source: 'SyncDatabasesJob', + meta: { databaseId: db.id, error: db.error } + }); + } + } + + const message = `Sync completed: ${result.successCount} successful, ${result.failureCount} failed (${result.totalChecked} total)`; + + if (result.failureCount > 0 && result.successCount === 0) { + return { + success: false, + error: message + }; + } + + return { + success: true, + output: message + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } +}; diff --git a/src/lib/server/jobs/init.ts b/src/lib/server/jobs/init.ts index 684504f..1c5006e 100644 --- a/src/lib/server/jobs/init.ts +++ b/src/lib/server/jobs/init.ts @@ -6,6 +6,7 @@ import { logger } from '$logger/logger.ts'; import { cleanupLogsJob } from './definitions/cleanupLogs.ts'; import { createBackupJob } from './definitions/createBackup.ts'; import { cleanupBackupsJob } from './definitions/cleanupBackups.ts'; +import { syncDatabasesJob } from './definitions/syncDatabases.ts'; /** * Register all job definitions @@ -15,6 +16,7 @@ function registerAllJobs(): void { jobRegistry.register(cleanupLogsJob); jobRegistry.register(createBackupJob); jobRegistry.register(cleanupBackupsJob); + jobRegistry.register(syncDatabasesJob); } /** diff --git a/src/lib/server/jobs/logic/createBackup.ts b/src/lib/server/jobs/logic/createBackup.ts index a4921f9..dc77aee 100644 --- a/src/lib/server/jobs/logic/createBackup.ts +++ b/src/lib/server/jobs/logic/createBackup.ts @@ -54,7 +54,7 @@ export async function createBackup( error: `Source path is not a directory: ${sourceDir}`, }; } - } catch (error) { + } catch (_error) { return { success: false, error: `Source directory does not exist: ${sourceDir}`, diff --git a/src/lib/server/jobs/logic/syncDatabases.ts b/src/lib/server/jobs/logic/syncDatabases.ts new file mode 100644 index 0000000..110f495 --- /dev/null +++ b/src/lib/server/jobs/logic/syncDatabases.ts @@ -0,0 +1,139 @@ +/** + * Core sync logic for PCD auto-sync + * Checks for databases that need syncing and pulls updates if auto_pull is enabled + */ + +import { pcdManager } from '$pcd/pcd.ts'; +import { notificationManager } from '$notifications/NotificationManager.ts'; + +export interface DatabaseSyncStatus { + id: number; + name: string; + success: boolean; + updatesPulled: number; + error?: string; +} + +export interface SyncDatabasesResult { + totalChecked: number; + successCount: number; + failureCount: number; + databases: DatabaseSyncStatus[]; +} + +/** + * Sync all databases that are due for auto-sync + * Checks databases with sync_strategy > 0 that haven't been synced within their interval + */ +export async function syncDatabases(): Promise { + const databases = pcdManager.getDueForSync(); + + const totalChecked = databases.length; + let successCount = 0; + let failureCount = 0; + const statuses: DatabaseSyncStatus[] = []; + + for (const db of databases) { + try { + // Check for updates + const updateInfo = await pcdManager.checkForUpdates(db.id); + + if (!updateInfo.hasUpdates) { + // No updates available, just mark as checked + statuses.push({ + id: db.id, + name: db.name, + success: true, + updatesPulled: 0 + }); + successCount++; + continue; + } + + // Updates are available + if (db.auto_pull === 1) { + // Auto-pull is enabled, sync the database + const syncResult = await pcdManager.sync(db.id); + + if (syncResult.success) { + // Send success notification + await notificationManager.notify({ + type: 'pcd.sync_success', + title: 'Database Synced Successfully', + message: `Database "${db.name}" has been updated (${syncResult.commitsBehind} commit${syncResult.commitsBehind === 1 ? '' : 's'} pulled)`, + metadata: { + databaseId: db.id, + databaseName: db.name, + commitsPulled: syncResult.commitsBehind + } + }); + + statuses.push({ + id: db.id, + name: db.name, + success: true, + updatesPulled: syncResult.commitsBehind + }); + successCount++; + } else { + // Send failure notification + await notificationManager.notify({ + type: 'pcd.sync_failed', + title: 'Database Sync Failed', + message: `Failed to sync database "${db.name}": ${syncResult.error}`, + metadata: { + databaseId: db.id, + databaseName: db.name, + error: syncResult.error + } + }); + + statuses.push({ + id: db.id, + name: db.name, + success: false, + updatesPulled: 0, + error: syncResult.error + }); + failureCount++; + } + } else { + // Auto-pull is disabled, send notification + await notificationManager.notify({ + type: 'pcd.updates_available', + title: 'Database Updates Available', + message: `Updates are available for database "${db.name}" (${updateInfo.commitsBehind} commit${updateInfo.commitsBehind === 1 ? '' : 's'} behind)`, + metadata: { + databaseId: db.id, + databaseName: db.name, + commitsBehind: updateInfo.commitsBehind + } + }); + + statuses.push({ + id: db.id, + name: db.name, + success: true, + updatesPulled: 0 + }); + successCount++; + } + } catch (error) { + statuses.push({ + id: db.id, + name: db.name, + success: false, + updatesPulled: 0, + error: error instanceof Error ? error.message : 'Unknown error' + }); + failureCount++; + } + } + + return { + totalChecked, + successCount, + failureCount, + databases: statuses + }; +} diff --git a/src/lib/server/pcd/deps.ts b/src/lib/server/pcd/deps.ts new file mode 100644 index 0000000..17ca190 --- /dev/null +++ b/src/lib/server/pcd/deps.ts @@ -0,0 +1,106 @@ +/** + * PCD Dependency Resolution + * Handles cloning and managing PCD dependencies + */ + +import * as git from '$utils/git/git.ts'; +import { loadManifest } from './manifest.ts'; + +/** + * Extract repository name from GitHub URL + * https://github.com/Dictionarry-Hub/schema -> schema + */ +function getRepoName(repoUrl: string): string { + const parts = repoUrl.split('/'); + return parts[parts.length - 1]; +} + +/** + * Get dependency path + */ +function getDependencyPath(pcdPath: string, repoName: string): string { + return `${pcdPath}/deps/${repoName}`; +} + +/** + * Clone and checkout a single dependency + */ +async function cloneDependency( + pcdPath: string, + repoUrl: string, + version: string +): Promise { + const repoName = getRepoName(repoUrl); + const depPath = getDependencyPath(pcdPath, repoName); + + // Clone the dependency repository + await git.clone(repoUrl, depPath); + + // Checkout the specific version tag + await git.checkout(depPath, version); +} + +/** + * Process all dependencies for a PCD + * Clones dependencies and validates their manifests + */ +export async function processDependencies(pcdPath: string): Promise { + // Load the PCD's manifest + const manifest = await loadManifest(pcdPath); + + // Skip if no dependencies + if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { + return; + } + + // Create deps directory + const depsDir = `${pcdPath}/deps`; + await Deno.mkdir(depsDir, { recursive: true }); + + // Process each dependency + for (const [repoUrl, version] of Object.entries(manifest.dependencies)) { + // Clone and checkout the dependency + await cloneDependency(pcdPath, repoUrl, version); + + // Validate the dependency's manifest + const repoName = getRepoName(repoUrl); + const depPath = getDependencyPath(pcdPath, repoName); + await loadManifest(depPath); + + // TODO (post-2.0): Recursively process nested dependencies + // For now, we only support one level of dependencies + } +} + +/** + * Check if all dependencies are present and valid + */ +export async function validateDependencies(pcdPath: string): Promise { + try { + const manifest = await loadManifest(pcdPath); + + // If no dependencies, validation passes + if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { + return true; + } + + for (const [repoUrl] of Object.entries(manifest.dependencies)) { + const repoName = getRepoName(repoUrl); + const depPath = getDependencyPath(pcdPath, repoName); + + // Check if dependency directory exists + try { + await Deno.stat(depPath); + } catch { + return false; + } + + // Validate dependency manifest + await loadManifest(depPath); + } + + return true; + } catch { + return false; + } +} diff --git a/src/lib/server/pcd/manifest.ts b/src/lib/server/pcd/manifest.ts new file mode 100644 index 0000000..5c75afc --- /dev/null +++ b/src/lib/server/pcd/manifest.ts @@ -0,0 +1,152 @@ +/** + * PCD Manifest Parser and Validator + * Handles reading and validating pcd.json files + */ + +export interface Manifest { + name: string; + version: string; + description: string; + dependencies?: Record; + arr_types?: string[]; + authors?: Array<{ name: string; email?: string }>; + license?: string; + repository?: string; + tags?: string[]; + links?: { + homepage?: string; + documentation?: string; + issues?: string; + }; + profilarr: { + minimum_version: string; + }; +} + +export class ManifestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ManifestValidationError'; + } +} + +/** + * Read manifest from a PCD repository + */ +export async function readManifest(pcdPath: string): Promise { + const manifestPath = `${pcdPath}/pcd.json`; + + try { + const manifestContent = await Deno.readTextFile(manifestPath); + const manifest = JSON.parse(manifestContent); + return manifest; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new ManifestValidationError('pcd.json not found in repository'); + } + if (error instanceof SyntaxError) { + throw new ManifestValidationError('pcd.json contains invalid JSON'); + } + throw error; + } +} + +/** + * Validate a manifest object + */ +export function validateManifest(manifest: unknown): asserts manifest is Manifest { + if (!manifest || typeof manifest !== 'object') { + throw new ManifestValidationError('Manifest must be an object'); + } + + const m = manifest as Record; + + // Required fields + if (typeof m.name !== 'string' || !m.name) { + throw new ManifestValidationError('Manifest missing required field: name'); + } + + if (typeof m.version !== 'string' || !m.version) { + throw new ManifestValidationError('Manifest missing required field: version'); + } + + if (typeof m.description !== 'string' || !m.description) { + throw new ManifestValidationError('Manifest missing required field: description'); + } + + // Validate dependencies if present + if (m.dependencies !== undefined) { + if (typeof m.dependencies !== 'object' || m.dependencies === null) { + throw new ManifestValidationError('Manifest field dependencies must be an object'); + } + + // Validate dependencies includes schema (only check for non-empty dependencies) + const deps = m.dependencies as Record; + if (Object.keys(deps).length > 0) { + const hasSchema = Object.keys(deps).some((url) => url.includes('/schema')); + if (!hasSchema) { + throw new ManifestValidationError('Manifest dependencies must include schema repository'); + } + } + } + + // Validate profilarr section + if (!m.profilarr || typeof m.profilarr !== 'object') { + throw new ManifestValidationError('Manifest missing required field: profilarr'); + } + + const profilarr = m.profilarr as Record; + if (typeof profilarr.minimum_version !== 'string' || !profilarr.minimum_version) { + throw new ManifestValidationError('Manifest missing required field: profilarr.minimum_version'); + } + + // Optional fields validation + if (m.arr_types !== undefined) { + if (!Array.isArray(m.arr_types)) { + throw new ManifestValidationError('Manifest field arr_types must be an array'); + } + const validTypes = ['radarr', 'sonarr', 'readarr', 'lidarr', 'prowlarr', 'whisparr']; + for (const type of m.arr_types) { + if (typeof type !== 'string' || !validTypes.includes(type)) { + throw new ManifestValidationError( + `Invalid arr_type: ${type}. Must be one of: ${validTypes.join(', ')}` + ); + } + } + } + + if (m.authors !== undefined) { + if (!Array.isArray(m.authors)) { + throw new ManifestValidationError('Manifest field authors must be an array'); + } + for (const author of m.authors) { + if (!author || typeof author !== 'object') { + throw new ManifestValidationError('Each author must be an object'); + } + const a = author as Record; + if (typeof a.name !== 'string' || !a.name) { + throw new ManifestValidationError('Each author must have a name'); + } + } + } + + if (m.tags !== undefined) { + if (!Array.isArray(m.tags)) { + throw new ManifestValidationError('Manifest field tags must be an array'); + } + for (const tag of m.tags) { + if (typeof tag !== 'string') { + throw new ManifestValidationError('Each tag must be a string'); + } + } + } +} + +/** + * Read and validate manifest from a PCD repository + */ +export async function loadManifest(pcdPath: string): Promise { + const manifest = await readManifest(pcdPath); + validateManifest(manifest); + return manifest; +} diff --git a/src/lib/server/pcd/paths.ts b/src/lib/server/pcd/paths.ts new file mode 100644 index 0000000..77976b1 --- /dev/null +++ b/src/lib/server/pcd/paths.ts @@ -0,0 +1,19 @@ +/** + * Helper functions for PCD paths + */ + +import { config } from '$config'; + +/** + * Get the filesystem path for a PCD repository + */ +export function getPCDPath(uuid: string): string { + return `${config.paths.databases}/${uuid}`; +} + +/** + * Get the manifest file path for a PCD repository + */ +export function getManifestPath(uuid: string): string { + return `${getPCDPath(uuid)}/pcd.json`; +} diff --git a/src/lib/server/pcd/pcd.ts b/src/lib/server/pcd/pcd.ts new file mode 100644 index 0000000..9d4c44a --- /dev/null +++ b/src/lib/server/pcd/pcd.ts @@ -0,0 +1,242 @@ +/** + * PCD Manager - High-level orchestration for PCD lifecycle + */ + +import * as git from '$utils/git/git.ts'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import type { DatabaseInstance } from '$db/queries/databaseInstances.ts'; +import { loadManifest, type Manifest } from './manifest.ts'; +import { getPCDPath } from './paths.ts'; +import { processDependencies } from './deps.ts'; +import { notificationManager } from '$notifications/NotificationManager.ts'; + +export interface LinkOptions { + repositoryUrl: string; + name: string; + branch?: string; + syncStrategy?: number; + autoPull?: boolean; + personalAccessToken?: string; +} + +export interface SyncResult { + success: boolean; + commitsBehind: number; + error?: string; +} + +/** + * PCD Manager - Manages the lifecycle of Profilarr Compliant Databases + */ +class PCDManager { + /** + * Link a new PCD repository + */ + async link(options: LinkOptions): Promise { + // Generate UUID for storage + const uuid = crypto.randomUUID(); + const localPath = getPCDPath(uuid); + + try { + // Clone the repository and detect if it's private + const isPrivate = await git.clone(options.repositoryUrl, localPath, options.branch, options.personalAccessToken); + + // Validate manifest (loadManifest throws if invalid) + await loadManifest(localPath); + + // Process dependencies (clone and validate) + await processDependencies(localPath); + + // Insert into database + const id = databaseInstancesQueries.create({ + uuid, + name: options.name, + repositoryUrl: options.repositoryUrl, + localPath, + syncStrategy: options.syncStrategy, + autoPull: options.autoPull, + personalAccessToken: options.personalAccessToken, + isPrivate + }); + + // Get and return the created instance + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error('Failed to retrieve created database instance'); + } + + // Send notification + await notificationManager.notify({ + type: 'pcd.linked', + title: 'Database Linked', + message: `Database "${options.name}" has been linked successfully`, + metadata: { + databaseId: id, + databaseName: options.name, + repositoryUrl: options.repositoryUrl + } + }); + + return instance; + } catch (error) { + // Cleanup on failure - remove cloned directory + try { + await Deno.remove(localPath, { recursive: true }); + } catch { + // Ignore cleanup errors + } + throw error; + } + } + + /** + * Unlink a PCD repository + */ + async unlink(id: number): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + // Store name and URL for notification + const { name, repository_url } = instance; + + // Delete from database first + databaseInstancesQueries.delete(id); + + // Then cleanup filesystem + try { + await Deno.remove(instance.local_path, { recursive: true }); + } catch (error) { + // Log but don't throw - database entry is already deleted + console.error(`Failed to remove PCD directory ${instance.local_path}:`, error); + } + + // Send notification + await notificationManager.notify({ + type: 'pcd.unlinked', + title: 'Database Unlinked', + message: `Database "${name}" has been removed`, + metadata: { + databaseId: id, + databaseName: name, + repositoryUrl: repository_url + } + }); + } + + /** + * Sync a PCD repository (pull updates) + */ + async sync(id: number): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + try { + // Check for updates first + const updateInfo = await git.checkForUpdates(instance.local_path); + + if (!updateInfo.hasUpdates) { + // Already up to date + databaseInstancesQueries.updateSyncedAt(id); + return { + success: true, + commitsBehind: 0 + }; + } + + // Pull updates + await git.pull(instance.local_path); + + // Update last_synced_at + databaseInstancesQueries.updateSyncedAt(id); + + return { + success: true, + commitsBehind: updateInfo.commitsBehind + }; + } catch (error) { + return { + success: false, + commitsBehind: 0, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Check for available updates without pulling + */ + async checkForUpdates(id: number): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + return await git.checkForUpdates(instance.local_path); + } + + /** + * Get parsed manifest for a PCD + */ + async getManifest(id: number): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + return await loadManifest(instance.local_path); + } + + /** + * Switch branch for a PCD + */ + async switchBranch(id: number, branch: string): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + await git.checkout(instance.local_path, branch); + await git.pull(instance.local_path); + databaseInstancesQueries.updateSyncedAt(id); + } + + /** + * Get git status for a PCD + */ + async getStatus(id: number): Promise { + const instance = databaseInstancesQueries.getById(id); + if (!instance) { + throw new Error(`Database instance ${id} not found`); + } + + return await git.getStatus(instance.local_path); + } + + /** + * Get all PCDs + */ + getAll(): DatabaseInstance[] { + return databaseInstancesQueries.getAll(); + } + + /** + * Get PCD by ID + */ + getById(id: number): DatabaseInstance | undefined { + return databaseInstancesQueries.getById(id); + } + + /** + * Get PCDs that need auto-sync + */ + getDueForSync(): DatabaseInstance[] { + return databaseInstancesQueries.getDueForSync(); + } +} + +// Export singleton instance +export const pcdManager = new PCDManager(); diff --git a/src/lib/server/utils/config/config.ts b/src/lib/server/utils/config/config.ts index e5b7ffd..860b538 100644 --- a/src/lib/server/utils/config/config.ts +++ b/src/lib/server/utils/config/config.ts @@ -26,6 +26,7 @@ class Config { await Deno.mkdir(this.paths.logs, { recursive: true }); await Deno.mkdir(this.paths.data, { recursive: true }); await Deno.mkdir(this.paths.backups, { recursive: true }); + await Deno.mkdir(this.paths.databases, { recursive: true }); } /** @@ -54,6 +55,9 @@ class Config { get database(): string { return `${config.basePath}/data/profilarr.db`; }, + get databases(): string { + return `${config.basePath}/data/databases`; + }, get backups(): string { return `${config.basePath}/backups`; } diff --git a/src/lib/server/utils/git/git.ts b/src/lib/server/utils/git/git.ts new file mode 100644 index 0000000..a09d202 --- /dev/null +++ b/src/lib/server/utils/git/git.ts @@ -0,0 +1,299 @@ +/** + * Git utility functions for managing database repositories + */ + +export interface GitStatus { + currentBranch: string; + isDirty: boolean; + untracked: string[]; + modified: string[]; + staged: string[]; +} + +export interface UpdateInfo { + hasUpdates: boolean; + commitsBehind: number; + commitsAhead: number; + latestRemoteCommit: string; + currentLocalCommit: string; +} + +/** + * Execute a git command with sandboxed environment (no system credentials) + */ +async function execGit(args: string[], cwd?: string): Promise { + const command = new Deno.Command('git', { + args, + cwd, + stdout: 'piped', + stderr: 'piped', + env: { + // Disable all credential helpers and interactive prompts + GIT_TERMINAL_PROMPT: '0', // Fail instead of prompting (git 2.3+) + GIT_ASKPASS: 'echo', // Return empty on credential requests + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH password prompts + // Clear credential helpers via environment config + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '' + } + }); + + const { code, stdout, stderr } = await command.output(); + + if (code !== 0) { + const errorMessage = new TextDecoder().decode(stderr); + throw new Error(`Git command failed: ${errorMessage}`); + } + + return new TextDecoder().decode(stdout).trim(); +} + +/** + * Validate that a repository URL is accessible and detect if it's private using GitHub API + * Returns true if the repository is private, false if public + */ +async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise { + // Validate GitHub URL format and extract owner/repo + const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/; + const normalizedUrl = repositoryUrl.replace(/\.git$/, ''); + const match = normalizedUrl.match(githubPattern); + + if (!match) { + throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)'); + } + + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}`; + + // First try without authentication to check if it's public + try { + const response = await globalThis.fetch(apiUrl, { + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Profilarr' + } + }); + + if (response.ok) { + const data = await response.json(); + // Repository is accessible without auth + return data.private === true; + } + + // 404 or 403 means repo doesn't exist or is private + if (response.status === 404 || response.status === 403) { + // If we have a PAT, try with authentication + if (personalAccessToken) { + const authResponse = await globalThis.fetch(apiUrl, { + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${personalAccessToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Profilarr' + } + }); + + if (authResponse.ok) { + const data = await authResponse.json(); + return data.private === true; + } + + if (authResponse.status === 404) { + throw new Error('Repository not found. Please check the URL.'); + } + + if (authResponse.status === 401 || authResponse.status === 403) { + throw new Error('Unable to access repository. Please check your Personal Access Token has the correct permissions (repo scope required).'); + } + + throw new Error(`GitHub API error: ${authResponse.status} ${authResponse.statusText}`); + } + + throw new Error('Repository not found or is private. Please provide a Personal Access Token if this is a private repository.'); + } + + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } catch (error) { + if (error instanceof Error && ( + error.message.includes('Repository not found') || + error.message.includes('Unable to access') || + error.message.includes('GitHub API error') + )) { + throw error; + } + throw new Error(`Failed to validate repository: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Clone a git repository + * Returns true if the repository is private, false if public + */ +export async function clone( + repositoryUrl: string, + targetPath: string, + branch?: string, + personalAccessToken?: string +): Promise { + // Validate repository exists and detect if it's private + const isPrivate = await validateRepository(repositoryUrl, personalAccessToken); + + const args = ['clone']; + + if (branch) { + args.push('--branch', branch); + } + + // Inject personal access token into URL if provided (for private repos or push access) + let authUrl = repositoryUrl; + if (personalAccessToken) { + // Format: https://TOKEN@github.com/username/repo + authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`); + } + + args.push(authUrl, targetPath); + + await execGit(args); + + return isPrivate; +} + +/** + * Pull latest changes from remote + */ +export async function pull(repoPath: string): Promise { + await execGit(['pull'], repoPath); +} + +/** + * Fetch from remote without merging + */ +export async function fetchRemote(repoPath: string): Promise { + await execGit(['fetch'], repoPath); +} + +/** + * Get current branch name + */ +export async function getCurrentBranch(repoPath: string): Promise { + return await execGit(['branch', '--show-current'], repoPath); +} + +/** + * Checkout a branch + */ +export async function checkout(repoPath: string, branch: string): Promise { + await execGit(['checkout', branch], repoPath); +} + +/** + * Get repository status + */ +export async function getStatus(repoPath: string): Promise { + const currentBranch = await getCurrentBranch(repoPath); + + // Get short status + const statusOutput = await execGit(['status', '--short'], repoPath); + + const untracked: string[] = []; + const modified: string[] = []; + const staged: string[] = []; + + for (const line of statusOutput.split('\n')) { + if (!line.trim()) continue; + + const status = line.substring(0, 2); + const file = line.substring(3); + + if (status.startsWith('??')) { + untracked.push(file); + } else if (status[1] === 'M' || status[1] === 'D') { + modified.push(file); + } else if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') { + staged.push(file); + } + } + + const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0; + + return { + currentBranch, + isDirty, + untracked, + modified, + staged + }; +} + +/** + * Check for updates from remote + */ +export async function checkForUpdates(repoPath: string): Promise { + // Fetch latest from remote + await fetchRemote(repoPath); + + const currentBranch = await getCurrentBranch(repoPath); + const remoteBranch = `origin/${currentBranch}`; + + // Get current commit + const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath); + + // Get remote commit + let latestRemoteCommit: string; + try { + latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath); + } catch { + // Remote branch doesn't exist or hasn't been fetched + return { + hasUpdates: false, + commitsBehind: 0, + commitsAhead: 0, + latestRemoteCommit: currentLocalCommit, + currentLocalCommit + }; + } + + // Count commits behind + let commitsBehind = 0; + try { + const behindOutput = await execGit( + ['rev-list', '--count', `HEAD..${remoteBranch}`], + repoPath + ); + commitsBehind = parseInt(behindOutput) || 0; + } catch { + commitsBehind = 0; + } + + // Count commits ahead + let commitsAhead = 0; + try { + const aheadOutput = await execGit( + ['rev-list', '--count', `${remoteBranch}..HEAD`], + repoPath + ); + commitsAhead = parseInt(aheadOutput) || 0; + } catch { + commitsAhead = 0; + } + + return { + hasUpdates: commitsBehind > 0, + commitsBehind, + commitsAhead, + latestRemoteCommit, + currentLocalCommit + }; +} + +/** + * Reset repository to match remote (discards local changes) + */ +export async function resetToRemote(repoPath: string): Promise { + const currentBranch = await getCurrentBranch(repoPath); + const remoteBranch = `origin/${currentBranch}`; + + await execGit(['reset', '--hard', remoteBranch], repoPath); +} diff --git a/src/lib/server/utils/logger/logger.ts b/src/lib/server/utils/logger/logger.ts index 620bf79..6403780 100644 --- a/src/lib/server/utils/logger/logger.ts +++ b/src/lib/server/utils/logger/logger.ts @@ -54,21 +54,24 @@ class Logger { * Check if logging is enabled */ private isEnabled(): boolean { - return this.config.enabled; + const currentSettings = logSettings.get(); + return currentSettings.enabled === 1; } /** * Check if file logging is enabled */ private isFileLoggingEnabled(): boolean { - return this.config.fileLogging; + const currentSettings = logSettings.get(); + return currentSettings.file_logging === 1; } /** * Check if console logging is enabled */ private isConsoleLoggingEnabled(): boolean { - return this.config.consoleLogging; + const currentSettings = logSettings.get(); + return currentSettings.console_logging === 1; } /** @@ -79,8 +82,12 @@ class Logger { return false; } + // Get fresh settings from database instead of using cached config + const currentSettings = logSettings.get(); + const currentMinLevel = currentSettings.min_level; + const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"]; - const minIndex = levels.indexOf(this.config.minLevel); + const minIndex = levels.indexOf(currentMinLevel); const levelIndex = levels.indexOf(level); return levelIndex >= minIndex; diff --git a/src/lib/shared/notificationTypes.ts b/src/lib/shared/notificationTypes.ts new file mode 100644 index 0000000..901d123 --- /dev/null +++ b/src/lib/shared/notificationTypes.ts @@ -0,0 +1,118 @@ +/** + * Shared notification types for both backend and frontend + * Defines all available notification types and their metadata + */ + +export interface NotificationType { + id: string; + label: string; + category: string; + description?: string; +} + +/** + * All available notification types + */ +export const notificationTypes: NotificationType[] = [ + // Backups + { + id: 'job.create_backup.success', + label: 'Backup Created (Success)', + category: 'Backups', + description: 'Notification when a backup is created successfully' + }, + { + id: 'job.create_backup.failed', + label: 'Backup Created (Failed)', + category: 'Backups', + description: 'Notification when backup creation fails' + }, + { + id: 'job.cleanup_backups.success', + label: 'Backup Cleanup (Success)', + category: 'Backups', + description: 'Notification when old backups are cleaned up successfully' + }, + { + id: 'job.cleanup_backups.failed', + label: 'Backup Cleanup (Failed)', + category: 'Backups', + description: 'Notification when backup cleanup fails' + }, + + // Logs + { + id: 'job.cleanup_logs.success', + label: 'Log Cleanup (Success)', + category: 'Logs', + description: 'Notification when old logs are cleaned up successfully' + }, + { + id: 'job.cleanup_logs.failed', + label: 'Log Cleanup (Failed)', + category: 'Logs', + description: 'Notification when log cleanup fails' + }, + + // Database Sync + { + id: 'pcd.linked', + label: 'Database Linked', + category: 'Databases', + description: 'Notification when a new database is linked' + }, + { + id: 'pcd.unlinked', + label: 'Database Unlinked', + category: 'Databases', + description: 'Notification when a database is removed' + }, + { + id: 'pcd.updates_available', + label: 'Database Updates Available', + category: 'Databases', + description: 'Notification when database updates are available but auto-pull is disabled' + }, + { + id: 'pcd.sync_success', + label: 'Database Synced (Success)', + category: 'Databases', + description: 'Notification when a database is synced successfully' + }, + { + id: 'pcd.sync_failed', + label: 'Database Sync (Failed)', + category: 'Databases', + description: 'Notification when database sync fails' + } +]; + +/** + * Group notification types by category + */ +export function groupNotificationTypesByCategory(): Record { + return notificationTypes.reduce( + (acc, type) => { + if (!acc[type.category]) { + acc[type.category] = []; + } + acc[type.category].push(type); + return acc; + }, + {} as Record + ); +} + +/** + * Get all notification type IDs + */ +export function getAllNotificationTypeIds(): string[] { + return notificationTypes.map((type) => type.id); +} + +/** + * Validate if a notification type ID exists + */ +export function isValidNotificationType(typeId: string): boolean { + return notificationTypes.some((type) => type.id === typeId); +} diff --git a/src/routes/databases/+page.server.ts b/src/routes/databases/+page.server.ts new file mode 100644 index 0000000..1298b19 --- /dev/null +++ b/src/routes/databases/+page.server.ts @@ -0,0 +1,54 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, ServerLoad } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = () => { + const databases = pcdManager.getAll(); + + return { + databases + }; +}; + +export const actions = { + delete: async ({ request }) => { + const formData = await request.formData(); + const id = parseInt(formData.get('id')?.toString() || '0', 10); + + if (!id) { + await logger.warn('Attempted to unlink database without ID', { + source: 'databases' + }); + + return fail(400, { + error: 'Database ID is required' + }); + } + + try { + await pcdManager.unlink(id); + + await logger.info(`Unlinked database: ${id}`, { + source: 'databases', + meta: { id } + }); + + redirect(303, '/databases'); + } catch (error) { + // Re-throw redirect errors (they're not actual errors) + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + await logger.error('Failed to unlink database', { + source: 'databases', + meta: { error: error instanceof Error ? error.message : String(error) } + }); + + return fail(500, { + error: error instanceof Error ? error.message : 'Failed to unlink database' + }); + } + } +} satisfies Actions; diff --git a/src/routes/databases/+page.svelte b/src/routes/databases/+page.svelte new file mode 100644 index 0000000..6b69664 --- /dev/null +++ b/src/routes/databases/+page.svelte @@ -0,0 +1,223 @@ + + + + Databases - Profilarr + + +{#if data.databases.length === 0} + +{:else} +
+ +
+
+

Databases

+

+ Manage your linked Profilarr Compliant Databases +

+
+ + + Link Database + +
+ + + + +
handleRowClick(row)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && handleRowClick(row)} class="cursor-pointer"> + {#if column.key === 'name'} +
+ {row.name} avatar +
+
+ {row.name} +
+ {#if row.is_private} + + + Private + + {/if} + {#if row.personal_access_token} + + + Dev + + {/if} +
+
+ {:else if column.key === 'repository_url'} + + {row.repository_url.replace('https://github.com/', '')} + + {:else if column.key === 'sync_strategy'} + + {formatSyncStrategy(row.sync_strategy)} + + {:else if column.key === 'last_synced_at'} + + {formatLastSynced(row.last_synced_at)} + + {/if} +
+
+ + + + +
+
+{/if} + + + { + showUnlinkModal = false; + if (selectedDatabase) { + unlinkFormElement?.requestSubmit(); + } + }} + on:cancel={() => { + showUnlinkModal = false; + selectedDatabase = null; + }} +/> + + + diff --git a/src/routes/databases/[id]/+page.server.ts b/src/routes/databases/[id]/+page.server.ts new file mode 100644 index 0000000..6d85842 --- /dev/null +++ b/src/routes/databases/[id]/+page.server.ts @@ -0,0 +1,21 @@ +import { error } from '@sveltejs/kit'; +import type { ServerLoad } from '@sveltejs/kit'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; + +export const load: ServerLoad = ({ params }) => { + const id = parseInt(params.id || '', 10); + + if (isNaN(id)) { + error(400, 'Invalid database ID'); + } + + const database = databaseInstancesQueries.getById(id); + + if (!database) { + error(404, 'Database not found'); + } + + return { + database + }; +}; diff --git a/src/routes/databases/[id]/+page.svelte b/src/routes/databases/[id]/+page.svelte new file mode 100644 index 0000000..44cf492 --- /dev/null +++ b/src/routes/databases/[id]/+page.svelte @@ -0,0 +1,19 @@ + + + + {data.database.name} - Profilarr + + + + + + diff --git a/src/routes/databases/[id]/edit/+page.server.ts b/src/routes/databases/[id]/edit/+page.server.ts new file mode 100644 index 0000000..7079700 --- /dev/null +++ b/src/routes/databases/[id]/edit/+page.server.ts @@ -0,0 +1,172 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { pcdManager } from '$pcd/pcd.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = ({ params }) => { + const id = parseInt(params.id || '', 10); + + // Validate ID + if (isNaN(id)) { + error(404, `Invalid database ID: ${params.id}`); + } + + // Fetch the specific instance + const instance = databaseInstancesQueries.getById(id); + + if (!instance) { + error(404, `Database not found: ${id}`); + } + + return { + instance + }; +}; + +export const actions: Actions = { + default: async ({ params, request }) => { + const id = parseInt(params.id || '', 10); + + // Validate ID + if (isNaN(id)) { + await logger.warn('Update failed: Invalid database ID', { + source: 'databases/[id]/edit', + meta: { id: params.id } + }); + return fail(400, { error: 'Invalid database ID' }); + } + + // Fetch the instance to verify it exists + const instance = databaseInstancesQueries.getById(id); + + if (!instance) { + await logger.warn('Update failed: Database not found', { + source: 'databases/[id]/edit', + meta: { id } + }); + return fail(404, { error: 'Database not found' }); + } + + const formData = await request.formData(); + + const name = formData.get('name')?.toString().trim(); + const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10); + const autoPull = formData.get('auto_pull') === '1'; + + // Validation + if (!name) { + await logger.warn('Attempted to update database with missing required fields', { + source: 'databases/[id]/edit', + meta: { id, name } + }); + + return fail(400, { + error: 'Name is required', + values: { name } + }); + } + + // Check if name already exists (excluding current instance) + const existingWithName = databaseInstancesQueries.getAll().find( + (db) => db.name === name && db.id !== id + ); + + if (existingWithName) { + await logger.warn('Attempted to update database with duplicate name', { + source: 'databases/[id]/edit', + meta: { id, name } + }); + + return fail(400, { + error: 'A database with this name already exists', + values: { name } + }); + } + + try { + // Update the database + const updated = databaseInstancesQueries.update(id, { + name, + syncStrategy, + autoPull + }); + + if (!updated) { + throw new Error('Update returned false'); + } + + await logger.info(`Updated database: ${name}`, { + source: 'databases/[id]/edit', + meta: { id, name } + }); + + // Redirect to database detail page + redirect(303, `/databases/${id}`); + } catch (error) { + // Re-throw redirect errors (they're not actual errors) + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + await logger.error('Failed to update database', { + source: 'databases/[id]/edit', + meta: { error: error instanceof Error ? error.message : String(error) } + }); + + return fail(500, { + error: 'Failed to update database', + values: { name } + }); + } + }, + + delete: async ({ params }) => { + const id = parseInt(params.id || '', 10); + + // Validate ID + if (isNaN(id)) { + await logger.warn('Delete failed: Invalid database ID', { + source: 'databases/[id]/edit', + meta: { id: params.id } + }); + return fail(400, { error: 'Invalid database ID' }); + } + + // Fetch the instance to verify it exists + const instance = databaseInstancesQueries.getById(id); + + if (!instance) { + await logger.warn('Delete failed: Database not found', { + source: 'databases/[id]/edit', + meta: { id } + }); + return fail(404, { error: 'Database not found' }); + } + + try { + // Unlink the database + await pcdManager.unlink(id); + + await logger.info(`Unlinked database: ${instance.name}`, { + source: 'databases/[id]/edit', + meta: { id, name: instance.name, repositoryUrl: instance.repository_url } + }); + + // Redirect to databases list + redirect(303, '/databases'); + } catch (error) { + // Re-throw redirect errors (they're not actual errors) + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + await logger.error('Failed to unlink database', { + source: 'databases/[id]/edit', + meta: { error: error instanceof Error ? error.message : String(error) } + }); + + return fail(500, { error: 'Failed to unlink database' }); + } + } +}; diff --git a/src/routes/databases/[id]/edit/+page.svelte b/src/routes/databases/[id]/edit/+page.svelte new file mode 100644 index 0000000..230fb97 --- /dev/null +++ b/src/routes/databases/[id]/edit/+page.svelte @@ -0,0 +1,9 @@ + + + diff --git a/src/routes/databases/bruh/+page.server.ts b/src/routes/databases/bruh/+page.server.ts new file mode 100644 index 0000000..469e386 --- /dev/null +++ b/src/routes/databases/bruh/+page.server.ts @@ -0,0 +1,21 @@ +import type { ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = ({ url }) => { + const urlParam = url.searchParams.get('url') || ''; + const type = url.searchParams.get('type') || 'unknown'; + const name = url.searchParams.get('name') || ''; + const branch = url.searchParams.get('branch') || ''; + const syncStrategy = url.searchParams.get('sync_strategy') || ''; + const autoPull = url.searchParams.get('auto_pull') || ''; + + return { + url: urlParam, + type, + formData: { + name, + branch, + syncStrategy, + autoPull + } + }; +}; diff --git a/src/routes/databases/bruh/+page.svelte b/src/routes/databases/bruh/+page.svelte new file mode 100644 index 0000000..cdab57b --- /dev/null +++ b/src/routes/databases/bruh/+page.svelte @@ -0,0 +1,110 @@ + + + + Bruh - Profilarr + + +
+
+ +
+

+ If you insist... +

+
+ + +
+ {#if data.type === 'youtube' && embedUrl} +
+ +
+ {:else if data.type === 'twitter'} +
+

+ Here's your tweet: +

+ + {data.url} + +
+ {:else if data.type === 'reddit'} +
+

+ Here's your Reddit post: +

+ + {data.url} + +
+ {:else} +
+

+ Here's what you tried to link: +

+ + {data.url} + +
+ {/if} +
+ + +
+

+ You need to link a GitHub repository +

+ + + Try Again + +
+
+
diff --git a/src/routes/databases/components/InstanceForm.svelte b/src/routes/databases/components/InstanceForm.svelte new file mode 100644 index 0000000..4616993 --- /dev/null +++ b/src/routes/databases/components/InstanceForm.svelte @@ -0,0 +1,335 @@ + + +
+
+

{title}

+

+ {description} +

+
+ +
{ + isLoading = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || errorMessage); + } else if (result.type === 'redirect') { + // Don't show success message if redirecting to bruh page + if (result.location && !result.location.includes('/databases/bruh')) { + alertStore.add('success', successMessage); + } + } + await update(); + isLoading = false; + }; + }} + > + +
+

+ Database Details +

+ +
+ +
+ + +

+ A friendly name to identify this database +

+
+ + +
+ + + {#if mode === 'edit'} +

+ Repository URL cannot be changed after linking +

+ {:else} +

+ Git repository URL containing the PCD manifest +

+ {/if} +
+ + + {#if mode === 'create'} +
+ + +

+ Branch to checkout on link. Leave empty to use the default branch. You can change this later. +

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

+ Required for private repositories to clone and for developers to push back to GitHub. +

+
+
+
+ + +
+

+ Sync Settings +

+ +
+ +
+ + +

+ How often to check for updates from the remote repository +

+
+ + +
+ +
+ +

+ If enabled, updates will be pulled automatically. If disabled, you'll only receive + notifications when updates are available. +

+
+
+
+
+ + +
+ {#if mode === 'edit'} + + Cancel + + {/if} + +
+
+ + + {#if mode === 'edit'} +
+

Danger Zone

+

+ Once you unlink this database, there is no going back. All local data will be removed. +

+ + + +
+ {/if} +
+ + +{#if mode === 'edit'} + { + showDeleteModal = false; + deleteFormElement?.requestSubmit(); + }} + on:cancel={() => (showDeleteModal = false)} + /> +{/if} diff --git a/src/routes/databases/new/+page.server.ts b/src/routes/databases/new/+page.server.ts new file mode 100644 index 0000000..ab79be4 --- /dev/null +++ b/src/routes/databases/new/+page.server.ts @@ -0,0 +1,113 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, ServerLoad } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = ({ url }) => { + const name = url.searchParams.get('name') || ''; + const branch = url.searchParams.get('branch') || ''; + const syncStrategy = url.searchParams.get('sync_strategy') || ''; + const autoPull = url.searchParams.get('auto_pull') || ''; + + return { + formData: { + name, + branch, + syncStrategy, + autoPull + } + }; +}; + +export const actions = { + default: async ({ request }) => { + const formData = await request.formData(); + + const name = formData.get('name')?.toString().trim(); + const repositoryUrl = formData.get('repository_url')?.toString().trim(); + const branch = formData.get('branch')?.toString().trim() || undefined; + const syncStrategy = parseInt(formData.get('sync_strategy')?.toString() || '0', 10); + const autoPull = formData.get('auto_pull') === '1'; + const personalAccessToken = formData.get('personal_access_token')?.toString().trim() || undefined; + + // Validation + if (!name || !repositoryUrl) { + await logger.warn('Attempted to link database with missing required fields', { + source: 'databases/new', + meta: { name, repositoryUrl } + }); + + return fail(400, { + error: 'Name and repository URL are required', + values: { name, repository_url: repositoryUrl } + }); + } + + // Check for common non-GitHub URLs and redirect to bruh page + const bruhParams = new URLSearchParams({ + name: name, + branch: branch || '', + sync_strategy: syncStrategy.toString(), + auto_pull: autoPull ? '1' : '0' + }); + + if (repositoryUrl.includes('youtube.com') || repositoryUrl.includes('youtu.be')) { + redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=youtube&${bruhParams.toString()}`); + } + if (repositoryUrl.includes('twitter.com') || repositoryUrl.includes('x.com')) { + redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=twitter&${bruhParams.toString()}`); + } + if (repositoryUrl.includes('reddit.com')) { + redirect(303, `/databases/bruh?url=${encodeURIComponent(repositoryUrl)}&type=reddit&${bruhParams.toString()}`); + } + + // Check if name already exists + if (databaseInstancesQueries.nameExists(name)) { + await logger.warn('Attempted to link database with duplicate name', { + source: 'databases/new', + meta: { name } + }); + + return fail(400, { + error: 'A database with this name already exists', + values: { name, repository_url: repositoryUrl } + }); + } + + try { + // Link the database + const instance = await pcdManager.link({ + name, + repositoryUrl, + branch, + syncStrategy, + autoPull, + personalAccessToken + }); + + await logger.info(`Linked new database: ${name}`, { + source: 'databases/new', + meta: { id: instance.id, name, repositoryUrl } + }); + + // Redirect to databases list + redirect(303, '/databases'); + } catch (error) { + // Re-throw redirect errors (they're not actual errors) + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + await logger.error('Failed to link database', { + source: 'databases/new', + meta: { error: error instanceof Error ? error.message : String(error) } + }); + + return fail(500, { + error: error instanceof Error ? error.message : 'Failed to link database', + values: { name, repository_url: repositoryUrl } + }); + } + } +} satisfies Actions; diff --git a/src/routes/databases/new/+page.svelte b/src/routes/databases/new/+page.svelte new file mode 100644 index 0000000..f797bc8 --- /dev/null +++ b/src/routes/databases/new/+page.svelte @@ -0,0 +1,9 @@ + + + diff --git a/src/routes/settings/notifications/components/NotificationServiceForm.svelte b/src/routes/settings/notifications/components/NotificationServiceForm.svelte index e6937c7..4d4ac60 100644 --- a/src/routes/settings/notifications/components/NotificationServiceForm.svelte +++ b/src/routes/settings/notifications/components/NotificationServiceForm.svelte @@ -3,6 +3,7 @@ import { alertStore } from '$alerts/store'; import DiscordConfiguration from './DiscordConfiguration.svelte'; import { siDiscord } from 'simple-icons'; + import { groupNotificationTypesByCategory } from '$shared/notificationTypes'; export let mode: 'create' | 'edit' = 'create'; export let initialData: { @@ -15,31 +16,8 @@ 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 - ); + const groupedTypes = groupNotificationTypesByCategory(); // Check if a notification type should be checked by default function isTypeEnabled(typeId: string): boolean { diff --git a/src/routes/settings/notifications/edit/[id]/+page.server.ts b/src/routes/settings/notifications/edit/[id]/+page.server.ts index 568dcc1..051d1f4 100644 --- a/src/routes/settings/notifications/edit/[id]/+page.server.ts +++ b/src/routes/settings/notifications/edit/[id]/+page.server.ts @@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit'; import { logger } from '$logger/logger.ts'; import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; +import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts'; export const load = ({ params }: { params: { id: string } }) => { const { id } = params; @@ -84,22 +85,9 @@ export const actions: Actions = { 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'] : []) - ]; + // Get enabled notification types dynamically from all available types + const allTypeIds = getAllNotificationTypeIds(); + enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on'); } // Update the service diff --git a/src/routes/settings/notifications/new/+page.server.ts b/src/routes/settings/notifications/new/+page.server.ts index 3488ca6..2ac7ee9 100644 --- a/src/routes/settings/notifications/new/+page.server.ts +++ b/src/routes/settings/notifications/new/+page.server.ts @@ -2,6 +2,7 @@ import type { Actions, RequestEvent } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit'; import { logger } from '$logger/logger.ts'; import { notificationServicesQueries } from '$db/queries/notificationServices.ts'; +import { getAllNotificationTypeIds } from '$shared/notificationTypes.ts'; export const actions: Actions = { create: async ({ request }: RequestEvent) => { @@ -39,22 +40,9 @@ export const actions: Actions = { 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'] : []) - ]; + // Get enabled notification types dynamically from all available types + const allTypeIds = getAllNotificationTypeIds(); + enabledTypes = allTypeIds.filter((typeId) => formData.get(typeId) === 'on'); } // Generate UUID for the service diff --git a/svelte.config.js b/svelte.config.js index 37e5239..f6bb5cc 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -22,6 +22,7 @@ const config = { $server: './src/server', $db: './src/lib/server/db', $jobs: './src/lib/server/jobs', + $pcd: './src/lib/server/pcd', $arr: './src/lib/server/utils/arr', $http: './src/lib/server/utils/http', $utils: './src/lib/server/utils',