mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Normalize notification events and update grab flow
Introduce a NotificationEventConfig interface and validate NOTIFICATION_EVENTS with `satisfies` for stronger typing and normalized metadata shape. Replace escaped emoji sequences with literal emoji, simplify helper functions (getEventMeta/getEventTitle) to use the typed registry, and clean up titleByRequestType typing. In download-torrent.processor: include the requesting user when setting status to downloading to avoid an extra DB query, and use that returned user to enqueue a non-blocking `request_grabbed` notification. Docs: note that `request_grabbed` notifications are opt-in for existing backends. Tests: add messageLabel rendering tests for Apprise and ntfy providers to validate emoji, label text, and type-specific titles.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, string>,
|
||||
},
|
||||
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<string, string>,
|
||||
},
|
||||
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<string, NotificationEventConfig>;
|
||||
|
||||
/** 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<string, string>;
|
||||
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<string, string> }).titleByRequestType[requestType];
|
||||
const meta = getEventMeta(event);
|
||||
if (requestType && meta.titleByRequestType) {
|
||||
const typeTitle = meta.titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user