feat(notifications): refactor notification system to use a fluent builder pattern

This commit is contained in:
Sam Chau
2025-12-28 19:43:51 +10:30
parent b7efaa567c
commit 4e15ffa168
7 changed files with 193 additions and 124 deletions

View File

@@ -1,6 +1,7 @@
import { logger } from '$logger/logger.ts';
import { runUpgradeManager } from '../logic/upgradeManager.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
import { notify } from '$notifications/builder.ts';
import { NotificationTypes } from '$notifications/types.ts';
import type { JobDefinition, JobResult } from '../types.ts';
/**
@@ -80,25 +81,26 @@ export const upgradeManagerJob: JobDefinition = {
messageLines.push('');
}
let notificationType: string;
let title: string;
const notificationType =
result.failureCount === 0
? NotificationTypes.UPGRADE_SUCCESS
: result.successCount === 0
? NotificationTypes.UPGRADE_FAILED
: NotificationTypes.UPGRADE_PARTIAL;
if (result.failureCount === 0) {
notificationType = 'upgrade.success';
title = hasDryRun ? 'Upgrade Completed (Dry Run)' : 'Upgrade Completed';
} else if (result.successCount === 0) {
notificationType = 'upgrade.failed';
title = 'Upgrade Failed';
} else {
notificationType = 'upgrade.partial';
title = 'Upgrade Partially Completed';
}
const title =
result.failureCount === 0
? hasDryRun
? 'Upgrade Completed (Dry Run)'
: 'Upgrade Completed'
: result.successCount === 0
? 'Upgrade Failed'
: 'Upgrade Partially Completed';
await notificationManager.notify({
type: notificationType,
title,
message: messageLines.join('\n').trim(),
metadata: {
await notify(notificationType)
.title(title)
.lines(messageLines)
.meta({
successCount: result.successCount,
failureCount: result.failureCount,
dryRun: hasDryRun,
@@ -108,8 +110,8 @@ export const upgradeManagerJob: JobDefinition = {
searched: i.itemsSearched,
items: i.items
}))
}
});
})
.send();
}
// Consider job failed only if all configs failed
@@ -132,12 +134,11 @@ export const upgradeManagerJob: JobDefinition = {
meta: { error: errorMessage }
});
await notificationManager.notify({
type: 'upgrade.failed',
title: 'Upgrade Failed',
message: `Upgrade manager encountered an error: ${errorMessage}`,
metadata: { error: errorMessage }
});
await notify(NotificationTypes.UPGRADE_FAILED)
.title('Upgrade Failed')
.message(`Upgrade manager encountered an error: ${errorMessage}`)
.meta({ error: errorMessage })
.send();
return {
success: false,

View File

@@ -4,7 +4,8 @@
*/
import { pcdManager } from '$pcd/pcd.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
import { notify } from '$notifications/builder.ts';
import { NotificationTypes } from '$notifications/types.ts';
export interface DatabaseSyncStatus {
id: number;
@@ -56,17 +57,11 @@ export async function syncDatabases(): Promise<SyncDatabasesResult> {
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
}
});
await notify(NotificationTypes.PCD_SYNC_SUCCESS)
.title('Database Synced Successfully')
.message(`Database "${db.name}" has been updated (${syncResult.commitsBehind} commit${syncResult.commitsBehind === 1 ? '' : 's'} pulled)`)
.meta({ databaseId: db.id, databaseName: db.name, commitsPulled: syncResult.commitsBehind })
.send();
statuses.push({
id: db.id,
@@ -76,17 +71,11 @@ export async function syncDatabases(): Promise<SyncDatabasesResult> {
});
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
}
});
await notify(NotificationTypes.PCD_SYNC_FAILED)
.title('Database Sync Failed')
.message(`Failed to sync database "${db.name}": ${syncResult.error}`)
.meta({ databaseId: db.id, databaseName: db.name, error: syncResult.error })
.send();
statuses.push({
id: db.id,
@@ -98,17 +87,12 @@ export async function syncDatabases(): Promise<SyncDatabasesResult> {
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
}
});
// Auto-pull is disabled, notify about available updates
await notify(NotificationTypes.PCD_UPDATES_AVAILABLE)
.title('Database Updates Available')
.message(`Updates are available for database "${db.name}" (${updateInfo.commitsBehind} commit${updateInfo.commitsBehind === 1 ? '' : 's'} behind)`)
.meta({ databaseId: db.id, databaseName: db.name, commitsBehind: updateInfo.commitsBehind })
.send();
statuses.push({
id: db.id,

View File

@@ -1,7 +1,8 @@
import { jobRegistry } from './registry.ts';
import { jobsQueries, jobRunsQueries } from '$db/queries/jobs.ts';
import { logger } from '$logger/logger.ts';
import { notificationManager } from '../notifications/NotificationManager.ts';
import { notify } from '$notifications/builder.ts';
import { NotificationTypes } from '$notifications/types.ts';
import type { Job, JobResult } from './types.ts';
/**
@@ -93,26 +94,16 @@ 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';
// Send notification
const notificationType = result.success
? NotificationTypes.jobSuccess(job.name)
: NotificationTypes.jobFailed(job.name);
await notificationManager.notify({
type: notificationType,
title: notificationTitle,
message: notificationMessage,
metadata: {
jobId: job.id,
jobName: job.name,
durationMs,
timestamp: finishedAt
}
});
await notify(notificationType)
.title(`${definition.description} - ${result.success ? 'Success' : 'Failed'}`)
.message(result.success ? (result.output ?? 'Job completed successfully') : (result.error ?? 'Unknown error'))
.meta({ jobId: job.id, jobName: job.name, durationMs, timestamp: finishedAt })
.send();
// Save job run to database
jobRunsQueries.create(

View File

@@ -0,0 +1,89 @@
/**
* Fluent notification builder
* Provides a chainable API for constructing and sending notifications
*/
import { notificationManager } from './NotificationManager.ts';
import type { Notification } from './types.ts';
/**
* Builder class for constructing notifications
*/
class NotificationBuilder {
private data: Notification;
constructor(type: string) {
this.data = {
type,
title: '',
message: ''
};
}
/**
* Set the notification title
*/
title(t: string): this {
this.data.title = t;
return this;
}
/**
* Set the notification message
*/
message(m: string): this {
this.data.message = m;
return this;
}
/**
* Build message from multiple lines
* Automatically filters out null/undefined/empty values
*/
lines(messageLines: (string | null | undefined | false)[]): this {
this.data.message = messageLines.filter(Boolean).join('\n').trim();
return this;
}
/**
* Set metadata
*/
meta(metadata: Record<string, unknown>): this {
this.data.metadata = metadata;
return this;
}
/**
* Send the notification
*/
async send(): Promise<void> {
await notificationManager.notify(this.data);
}
}
/**
* Create a new notification builder
*
* @example
* // Simple notification
* await notify('pcd.linked')
* .title('Database Linked')
* .message('Database "MyDB" has been linked successfully')
* .meta({ databaseId: 1 })
* .send();
*
* @example
* // Multi-line notification
* await notify('upgrade.success')
* .title('Upgrade Completed')
* .lines([
* 'Filter: 50 matched → 30 after cooldown',
* 'Selection: 10/10 items',
* hasItems ? `Items: ${items.join(', ')}` : null
* ])
* .meta({ instanceId: 1 })
* .send();
*/
export function notify(type: string): NotificationBuilder {
return new NotificationBuilder(type);
}

View File

@@ -2,11 +2,32 @@
* Core notification types and interfaces
*/
/**
* Type-safe notification type constants
*/
export const NotificationTypes = {
// Jobs (dynamic - constructed with job name)
jobSuccess: (jobName: string) => `job.${jobName}.success` as const,
jobFailed: (jobName: string) => `job.${jobName}.failed` as const,
// PCD / Databases
PCD_LINKED: 'pcd.linked',
PCD_UNLINKED: 'pcd.unlinked',
PCD_UPDATES_AVAILABLE: 'pcd.updates_available',
PCD_SYNC_SUCCESS: 'pcd.sync_success',
PCD_SYNC_FAILED: 'pcd.sync_failed',
// Upgrades
UPGRADE_SUCCESS: 'upgrade.success',
UPGRADE_PARTIAL: 'upgrade.partial',
UPGRADE_FAILED: 'upgrade.failed'
} as const;
/**
* Notification payload sent to services
*/
export interface Notification {
type: string; // e.g., 'job.backup.success', 'job.cleanup.failed'
type: string;
title: string;
message: string;
metadata?: Record<string, unknown>;

View File

@@ -8,7 +8,8 @@ import type { DatabaseInstance } from '$db/queries/databaseInstances.ts';
import { loadManifest, type Manifest } from './manifest.ts';
import { getPCDPath } from './paths.ts';
import { processDependencies, syncDependencies } from './deps.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
import { notify } from '$notifications/builder.ts';
import { NotificationTypes } from '$notifications/types.ts';
import { compile, invalidate, startWatch, getCache } from './cache.ts';
import { logger } from '$logger/logger.ts';
@@ -90,17 +91,11 @@ class PCDManager {
}
}
// 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
}
});
await notify(NotificationTypes.PCD_LINKED)
.title('Database Linked')
.message(`Database "${options.name}" has been linked successfully`)
.meta({ databaseId: id, databaseName: options.name, repositoryUrl: options.repositoryUrl })
.send();
return instance;
} catch (error) {
@@ -140,17 +135,11 @@ class PCDManager {
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
}
});
await notify(NotificationTypes.PCD_UNLINKED)
.title('Database Unlinked')
.message(`Database "${name}" has been removed`)
.meta({ databaseId: id, databaseName: name, repositoryUrl: repository_url })
.send();
}
/**

View File

@@ -3,7 +3,8 @@ import type { Actions, ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts';
import { logger } from '$logger/logger.ts';
import { notificationManager } from '$notifications/NotificationManager.ts';
import { notify } from '$notifications/builder.ts';
import { NotificationTypes } from '$notifications/types.ts';
import type { FilterConfig, FilterMode } from '$lib/shared/filters.ts';
import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts';
@@ -227,16 +228,15 @@ export const actions: Actions = {
const dryRunLabel = result.config.dryRun ? ' [DRY RUN]' : '';
const itemsList = result.selection.items.map((i) => i.title).join(', ');
await notificationManager.notify({
type: isSuccess ? 'upgrade.success' : 'upgrade.failed',
title: `${instance.name}: ${result.config.selectedFilter}${dryRunLabel}`,
message: [
await notify(isSuccess ? NotificationTypes.UPGRADE_SUCCESS : NotificationTypes.UPGRADE_FAILED)
.title(`${instance.name}: ${result.config.selectedFilter}${dryRunLabel}`)
.lines([
`Filter: ${result.filter.matchedCount} matched → ${result.filter.afterCooldown} after cooldown`,
`Selection: ${result.selection.actualCount}/${result.selection.requestedCount} items`,
`Results: ${result.results.searchesTriggered} searches, ${result.results.successful} successful`,
itemsList ? `Items: ${itemsList}` : null
].filter(Boolean).join('\n'),
metadata: {
])
.meta({
instanceId: id,
instanceName: instance.name,
filterName: result.config.selectedFilter,
@@ -244,8 +244,8 @@ export const actions: Actions = {
matchedCount: result.filter.matchedCount,
dryRun: result.config.dryRun,
items: result.selection.items.map((i) => i.title)
}
});
})
.send();
return {
success: true,
@@ -267,17 +267,11 @@ export const actions: Actions = {
meta: { instanceId: id, error: err }
});
await notificationManager.notify({
type: 'upgrade.failed',
title: 'Upgrade Failed',
message: `${instance.name}: ${errorMessage}`,
metadata: {
instanceId: id,
instanceName: instance.name,
error: errorMessage,
dryRun: true
}
});
await notify(NotificationTypes.UPGRADE_FAILED)
.title('Upgrade Failed')
.message(`${instance.name}: ${errorMessage}`)
.meta({ instanceId: id, instanceName: instance.name, error: errorMessage, dryRun: true })
.send();
return fail(500, { error: 'Upgrade run failed. Check logs for details.' });
}