Files
ReadMeABook/documentation/backend/services/notifications.md
T
xFlawless11x ba1efa88f5 feat: add On Grab notification event
Adds request_grabbed event that fires when a torrent/NZB is successfully
handed off to the configured download client, filling the gap between
request_approved (pre-search) and request_available (fully imported).

- Add request_grabbed to NOTIFICATION_EVENTS with titleByRequestType
  (Audiobook Grabbed / Ebook Grabbed), info severity, Details messageLabel
- Add NotificationEventConfig interface and update getEventMeta() return
  type to expose messageLabel to all providers without TypeScript errors
- Add messageLabel: 'Reason' to issue_reported event
- Fix all 4 providers (Discord, ntfy, Pushover, Apprise) to derive message
  field label from meta.messageLabel ?? 'Error' instead of hardcoded
  isIssue ternary — prevents grab details showing as Error
- Trigger request_grabbed in download-torrent.processor.ts after
  client.addDownload() succeeds; message carries torrent title, indexer,
  and download client name; requestType sourced from request.type
- Update notifications.md documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:49:36 -04:00

11 KiB

Notification System

Status: Implemented | Extensible notification system with Discord, ntfy, and Pushover support

Overview

Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.

Key Details

  • Backends: Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
  • Events: request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported
  • Encryption: AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
  • Delivery: Async via Bull job queue (priority 5)
  • Failure Handling: Non-blocking, Promise.allSettled (one backend fails, others succeed)

Database Schema

model NotificationBackend {
  id        String   @id @default(uuid())
  type      String   // 'apprise' | 'discord' | 'ntfy' | 'pushover'
  name      String   // User-friendly label
  config    Json     // Encrypted sensitive values
  events    Json     // Array of subscribed events
  enabled   Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Event Types

Event Trigger Notification Sent When
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_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

Dynamic Titles: Events can define titleByRequestType in notification-events.ts for type-specific titles.

  • request_grabbed + requestType: 'audiobook' → "Audiobook Grabbed"
  • request_grabbed + requestType: 'ebook' → "Ebook Grabbed"
  • request_available + requestType: 'audiobook' → "Audiobook Available"
  • request_available + requestType: 'ebook' → "Ebook Available"
  • request_available + no requestType → "Request Available" (fallback)
  • Use getEventTitle(event, requestType?) to resolve titles in providers

Notification Triggers

Request Creation (POST /api/requests)

  • Automatic search, approval needed: status === 'awaiting_approval' → request_pending_approval
  • Automatic search, auto-approved: status === 'pending' → request_approved
  • Interactive search: NO notification yet (deferred until torrent selection)

BookDate Swipe (POST /api/bookdate/swipe)

  • Right swipe, approval needed: status === 'awaiting_approval' → request_pending_approval
  • Right swipe, auto-approved: status === 'pending' → request_approved

Request with Pre-Selected Torrent (POST /api/audiobooks/request-with-torrent)

  • Approval needed: status === 'awaiting_approval' → request_pending_approval
  • Auto-approved: status === 'downloading' → request_approved

Torrent Selection for Existing Request (POST /api/requests/[id]/select-torrent)

  • Approval needed: status === 'awaiting_approval' → request_pending_approval
  • Auto-approved: status === 'downloading' → request_approved

Admin Approval (POST /api/admin/requests/[id]/approve)

  • Approve (with or without pre-selected torrent): After job triggered → request_approved
  • Deny: No notification

Download Grabbed (processor: download-torrent)

  • After client.addDownload() succeeds and DownloadHistory record created → request_grabbed
  • message field: "${torrent.title} via ${indexer} (${clientType})"
  • requestType: from request.type (audiobook/ebook)

Audiobook Available (processors: scan-plex, plex-recently-added)

  • After status: 'available' update → request_available (requestType: 'audiobook')
  • Includes user info in query (plexUsername)

Ebook Available (processor: organize-files)

  • After ebook status: 'downloaded' (terminal) → request_available (requestType: 'ebook')
  • Ebooks don't transition to 'available' via Plex matching

Request Error (processors: monitor-download, organize-files)

  • After status: 'failed' or status: 'warn' update → request_error
  • Includes error message in payload

Issue Reported (reported-issue.service.ts)

  • After user reports issue with available audiobook → issue_reported
  • Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)

Configuration Encryption

Encrypted Values:

  • Apprise: urls, authToken
  • Discord: webhookUrl
  • ntfy: accessToken
  • Pushover: userKey, appToken

Pattern: iv:authTag:encryptedData (base64)

Masking: Sensitive values returned as •••••••• in API responses

Preservation: Masked values preserved on update (if value === '••••••••', use existing encrypted value)

Message Formatting

Apprise (JSON via Apprise API):

  • Type: info (pending), success (approved/available), failure (error)
  • Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
  • Endpoint: {serverUrl}/notify/ (stateless) or {serverUrl}/notify/{configKey} (stateful)
  • Auth: Optional Bearer token via authToken config field
  • Format: Event title + book details + user + error (if applicable)

Discord (Rich Embeds):

  • Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
  • Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
  • Footer: Request/Issue ID
  • Timestamp: Event time

ntfy (JSON Publishing to Base URL):

  • Endpoint: POST to base serverUrl (default: https://ntfy.sh), topic in JSON body
  • Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
  • Priority: Default (3) for pending/approved, High (4) for available/error
  • Format: Event title + book details + user + error (if applicable)
  • Auth: Optional Bearer token via accessToken config field
  • Server: Configurable serverUrl (default: https://ntfy.sh)

Pushover (Plain Text with Emojis):

  • Emojis: 📬 📬 🎉
  • Priority: Normal (0) for pending/approved, High (1) for available/error
  • Format: Event title + book details + user + error (if applicable)

API Endpoints

GET /api/admin/notifications

  • Returns all backends (sensitive values masked)

POST /api/admin/notifications

  • Create backend (encrypts sensitive values)
  • Body: {type, name, config, events, enabled}

GET /api/admin/notifications/[id]

  • Get single backend (sensitive values masked)

PUT /api/admin/notifications/[id]

  • Update backend (preserves masked values, encrypts new values)

DELETE /api/admin/notifications/[id]

  • Delete backend

POST /api/admin/notifications/test

  • Test notification (synchronous, not via job queue)
  • Body: {type, config} (plaintext for testing)
  • Sends test payload: "The Hitchhiker's Guide to the Galaxy" by Douglas Adams

UI Components

NotificationsTab (src/app/admin/settings/tabs/NotificationsTab)

  • Type selector cards (Discord: indigo "D", Pushover: blue "P")
  • Configured backends grid (3 columns)
  • Backend cards: type icon, name, enabled status, event count, edit/delete actions
  • Modal: type-specific forms, event checkboxes, enable toggle, test button

Modal Features:

  • Type-first selection (user clicks "Add Discord" or "Add Pushover")
  • Password inputs for sensitive values
  • Event subscription checkboxes (5 events, default: available + error)
  • Test button (sends synchronous test notification)
  • Save button (validates and creates/updates backend)

Job Queue Integration

Job Type: send_notification (priority 5, concurrency 5)

Payload:

{
  jobId?: string,
  event: string,
  requestId: string,
  title: string,
  author: string,
  userName: string,
  message?: string,
  requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
  timestamp: Date
}

Processor: src/lib/processors/send-notification.processor.ts

  • Calls NotificationService.sendNotification()
  • Non-blocking error handling (logs but doesn't throw)

Queue Method: addNotificationJob(event, requestId, title, author, userName, message?, requestType?)

Architecture

Provider Pattern: INotificationProvider interface + registry (matches IAuthProvider pattern)

src/lib/services/notification/
  INotificationProvider.ts          # Interface + shared types
  notification.service.ts           # Core service with registry
  index.ts                          # Re-exports
  providers/
    apprise.provider.ts             # Apprise API (100+ services)
    discord.provider.ts             # Discord webhook
    ntfy.provider.ts                # ntfy API
    pushover.provider.ts            # Pushover API

Registry: Module-level Map<string, INotificationProvider> with registerProvider() / getProvider()

INotificationProvider interface:

  • type: string — provider identifier (registry key)
  • sensitiveFields: string[] — fields needing encryption/masking
  • metadata: ProviderMetadata — self-describing UI/validation metadata
  • send(config, payload): Promise<void> — receives decrypted config

ProviderMetadata: { type, displayName, description, iconLabel, iconColor, configFields[] } ProviderConfigField: { name, label, type, required, placeholder?, defaultValue?, options? }

Helper functions (notification.service.ts):

  • getRegisteredProviderTypes(): string[] — all registered type keys
  • getAllProviderMetadata(): ProviderMetadata[] — metadata for all providers

Helper functions (notification-events.ts):

  • getEventMeta(event) — raw event metadata (label, title, emoji, severity, priority)
  • getEventTitle(event, requestType?) — resolved title (checks titleByRequestType first, falls back to title)
  • getEventLabel(event) — human-readable label for UI

API Endpoint: GET /api/admin/notifications/providers — returns all provider metadata (admin-only)

Extensibility

Adding New Backend (2 steps):

  1. Create providers/email.provider.ts implementing INotificationProvider:
    • Set type = 'email', sensitiveFields = ['smtpPassword']
    • Set metadata with displayName, description, iconLabel, iconColor, configFields
    • Implement send() with email-specific logic
  2. Register in notification.service.ts: registerProvider(new EmailProvider()) + re-export from index.ts

No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.

Adding New Event (e.g., download_complete):

  1. Add entry to NOTIFICATION_EVENTS in notification-events.ts (label, title, emoji, severity, priority)
  2. Optionally add titleByRequestType for type-specific titles
  3. Add trigger point in processor, passing requestType if relevant
  4. Providers auto-resolve titles via getEventTitle() — no per-provider changes needed

Tech Stack

  • Bull (job queue)
  • Node.js crypto (AES-256-GCM encryption)
  • Apprise API, Discord webhooks, ntfy API, Pushover API
  • React (UI), Tailwind CSS (styling)