mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user