diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md index 9ad9be9..9bd8905 100644 --- a/documentation/backend/services/notifications.md +++ b/documentation/backend/services/notifications.md @@ -33,7 +33,7 @@ model NotificationBackend { |-------|---------|------------------------| | request_pending_approval | User creates request | Request needs admin approval | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | -| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) | +| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) — **opt-in: existing backends do not auto-subscribe; enable in Settings** | | request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) | | request_error | Download/import fails | Request failed at any stage | | issue_reported | User reports issue | User reports problem with available audiobook | diff --git a/src/lib/constants/notification-events.ts b/src/lib/constants/notification-events.ts index 37bfba4..d1e6542 100644 --- a/src/lib/constants/notification-events.ts +++ b/src/lib/constants/notification-events.ts @@ -1,4 +1,4 @@ -/** +/** * Component: Notification Event Constants * Documentation: documentation/backend/services/notifications.md * @@ -10,9 +10,9 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning'; export type NotificationPriority = 'normal' | 'high'; /** - * Central registry of notification events. + * Normalized interface for event metadata. + * Each entry in NOTIFICATION_EVENTS is structurally validated against this via `satisfies`. * - * Each entry defines: * - `label`: Human-readable name shown in the UI * - `title`: Default title used in notification messages * - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available") @@ -21,6 +21,17 @@ export type NotificationPriority = 'normal' | 'high'; * - `priority`: Drives notification urgency (Pushover/ntfy priority levels) * - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted) */ +export interface NotificationEventConfig { + label: string; + title: string; + titleByRequestType?: Record; + emoji: string; + severity: NotificationSeverity; + priority: NotificationPriority; + messageLabel?: string; +} + +/** Central registry of notification events. */ export const NOTIFICATION_EVENTS = { request_pending_approval: { label: 'Request Pending Approval', @@ -32,7 +43,7 @@ export const NOTIFICATION_EVENTS = { request_approved: { label: 'Request Approved', title: 'Request Approved', - emoji: '\u2705', + emoji: '✅', severity: 'success' as const, priority: 'normal' as const, }, @@ -42,7 +53,7 @@ export const NOTIFICATION_EVENTS = { titleByRequestType: { audiobook: 'Audiobook Grabbed', ebook: 'Ebook Grabbed', - } as Record, + }, emoji: '\u{1F4E5}', severity: 'info' as const, priority: 'normal' as const, @@ -54,7 +65,7 @@ export const NOTIFICATION_EVENTS = { titleByRequestType: { audiobook: 'Audiobook Available', ebook: 'Ebook Available', - } as Record, + }, emoji: '\u{1F389}', severity: 'success' as const, priority: 'high' as const, @@ -62,7 +73,7 @@ export const NOTIFICATION_EVENTS = { request_error: { label: 'Request Error', title: 'Request Error', - emoji: '\u274C', + emoji: '❌', severity: 'error' as const, priority: 'high' as const, }, @@ -74,7 +85,7 @@ export const NOTIFICATION_EVENTS = { priority: 'high' as const, messageLabel: 'Reason', }, -} as const; +} satisfies Record; /** Union type of all valid notification event keys */ export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS; @@ -85,24 +96,9 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti /** Metadata shape for a single notification event */ export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent]; -/** - * Normalized interface for event metadata consumed by providers. - * Broadens the `as const` literal union to make optional fields accessible. - */ -export interface NotificationEventConfig { - label: string; - title: string; - titleByRequestType?: Record; - emoji: string; - severity: NotificationSeverity; - priority: NotificationPriority; - /** Label for the `message` payload field. Defaults to "Error" in providers when absent. */ - messageLabel?: string; -} - /** Helper: get event metadata by key */ export function getEventMeta(event: NotificationEvent): NotificationEventConfig { - return NOTIFICATION_EVENTS[event] as NotificationEventConfig; + return NOTIFICATION_EVENTS[event]; } /** @@ -111,9 +107,9 @@ export function getEventMeta(event: NotificationEvent): NotificationEventConfig * returns the type-specific title. Otherwise falls back to the default `title`. */ export function getEventTitle(event: NotificationEvent, requestType?: string): string { - const meta = NOTIFICATION_EVENTS[event]; - if (requestType && 'titleByRequestType' in meta) { - const typeTitle = (meta as typeof meta & { titleByRequestType: Record }).titleByRequestType[requestType]; + const meta = getEventMeta(event); + if (requestType && meta.titleByRequestType) { + const typeTitle = meta.titleByRequestType[requestType]; if (typeTitle) return typeTitle; } return meta.title; diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index eabadfc..153da51 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P try { // Update request status to downloading - await prisma.request.update({ + const request = await prisma.request.update({ where: { id: requestId }, data: { status: 'downloading', progress: 0, updatedAt: new Date(), }, + include: { + user: { select: { plexUsername: true } }, + }, }); // Detect protocol from result and get appropriate client @@ -103,30 +106,20 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P logger.info(`Created download history record: ${downloadHistory.id}`); - // Send grab notification - const requestWithUser = await prisma.request.findUnique({ - where: { id: requestId }, - include: { - user: { select: { plexUsername: true } }, - }, - }); - + // Send grab notification (non-blocking — failures here don't fail the download) const jobQueue = getJobQueueService(); - - if (requestWithUser) { - const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`; - await jobQueue.addNotificationJob( - 'request_grabbed', - requestId, - audiobook.title, - audiobook.author, - requestWithUser.user.plexUsername || 'Unknown User', - grabMessage, - requestWithUser.type - ).catch((error) => { - logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) }); - }); - } + const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`; + await jobQueue.addNotificationJob( + 'request_grabbed', + requestId, + audiobook.title, + audiobook.author, + request.user.plexUsername || 'Unknown User', + grabMessage, + request.type + ).catch((error) => { + logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) }); + }); // Trigger monitor download job with initial delay await jobQueue.addMonitorJob( diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts index ac5ef8f..1d0bdea 100644 --- a/tests/services/apprise.provider.test.ts +++ b/tests/services/apprise.provider.test.ts @@ -458,6 +458,64 @@ describe('AppriseProvider', () => { }); }); + describe('messageLabel rendering by event', () => { + const basePayload = { + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date('2024-01-01T00:00:00Z'), + }; + + it('renders "⚠️ Error:" with error emoji for request_error', async () => { + fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' }); + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { serverUrl: 'http://apprise:8000', urls: 'slack://token' }, + { ...basePayload, event: 'request_error', message: 'Boom' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.body).toContain('⚠️ Error: Boom'); + expect(body.body).not.toContain('📝'); + }); + + it('renders "📝 Reason:" with note emoji for issue_reported', async () => { + fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' }); + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { serverUrl: 'http://apprise:8000', urls: 'slack://token' }, + { ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.body).toContain('📝 Reason: Chapter 3 cuts off'); + expect(body.body).not.toContain('⚠️'); + expect(body.body).not.toContain('Error:'); + }); + + it('renders "📝 Details:" with note emoji for request_grabbed', async () => { + fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' }); + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { serverUrl: 'http://apprise:8000', urls: 'slack://token' }, + { ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.body).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)'); + expect(body.body).not.toContain('⚠️'); + expect(body.body).not.toContain('Error:'); + expect(body.title).toBe('Audiobook Grabbed'); + }); + }); + describe('integration with NotificationService.sendToBackend', () => { it('decrypts sensitive fields and sends to Apprise', async () => { fetchMock.mockResolvedValue({ diff --git a/tests/services/ntfy.provider.test.ts b/tests/services/ntfy.provider.test.ts index 366daf3..430e048 100644 --- a/tests/services/ntfy.provider.test.ts +++ b/tests/services/ntfy.provider.test.ts @@ -267,6 +267,64 @@ describe('NtfyProvider', () => { }); }); + describe('messageLabel rendering by event', () => { + const basePayload = { + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date('2024-01-01T00:00:00Z'), + }; + + it('renders "⚠️ Error:" with error emoji for request_error', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) }); + const { NtfyProvider } = await import('@/lib/services/notification'); + const provider = new NtfyProvider(); + + await provider.send( + { topic: 'audiobooks' }, + { ...basePayload, event: 'request_error', message: 'Boom' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain('⚠️ Error: Boom'); + expect(body.message).not.toContain('📝'); + }); + + it('renders "📝 Reason:" with note emoji for issue_reported', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) }); + const { NtfyProvider } = await import('@/lib/services/notification'); + const provider = new NtfyProvider(); + + await provider.send( + { topic: 'audiobooks' }, + { ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain('📝 Reason: Chapter 3 cuts off'); + expect(body.message).not.toContain('⚠️'); + expect(body.message).not.toContain('Error:'); + }); + + it('renders "📝 Details:" with note emoji for request_grabbed', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) }); + const { NtfyProvider } = await import('@/lib/services/notification'); + const provider = new NtfyProvider(); + + await provider.send( + { topic: 'audiobooks' }, + { ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' } + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)'); + expect(body.message).not.toContain('⚠️'); + expect(body.message).not.toContain('Error:'); + expect(body.title).toBe('Audiobook Grabbed'); + }); + }); + describe('integration with NotificationService.sendToBackend', () => { it('decrypts accessToken and sends to ntfy', async () => { fetchMock.mockResolvedValue({