Add notification system with admin UI and backend

Introduces a full notification system with support for Discord and Pushover backends, event triggers, and message formatting. Adds backend services, processors, and API endpoints for managing notifications, as well as a new Notifications tab in the admin settings UI. Updates documentation, database schema, and tests to cover notification features and approval workflow improvements. Also changes project license from MIT to AGPL v3.
This commit is contained in:
kikootwo
2026-01-21 15:28:23 -05:00
parent ac2ad8aac2
commit dc7e557694
51 changed files with 5065 additions and 264 deletions
+5
View File
@@ -62,6 +62,11 @@
- **LOG_LEVEL configuration** → [backend/services/logging.md](backend/services/logging.md)
- **Job-aware database persistence** → [backend/services/logging.md](backend/services/logging.md)
## Notifications
- **Notification backends (Discord, Pushover)** → [backend/services/notifications.md](backend/services/notifications.md)
- **Event types, triggers, message formatting** → [backend/services/notifications.md](backend/services/notifications.md)
- **Notification settings UI** → [settings-pages.md](settings-pages.md)
## Frontend Components
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
+156 -14
View File
@@ -3,7 +3,7 @@
**Status:** ✅ Implemented | Admin approval workflow for user requests with global & per-user auto-approve controls
## Overview
Allows admins to review and approve/deny user requests before they are processed. Supports global auto-approve toggle and per-user auto-approve overrides.
Allows admins to review and approve/deny user requests before they are processed. Supports global auto-approve toggle and per-user auto-approve overrides. Interactive search requests store pre-selected torrents when approval is required.
## Key Details
@@ -21,20 +21,107 @@ Allows admins to review and approve/deny user requests before they are processed
- `false` = Always require approval for this user
### Approval Logic
**When user creates request:**
**When user creates request (automatic search via POST /api/requests):**
1. Check `User.autoApproveRequests`:
- If `true` → Set status to 'pending', trigger search job
- If `false` → Set status to 'awaiting_approval', wait for admin
- If `true` → Set status to 'pending', trigger search job, send approved notification
- If `false` → Set status to 'awaiting_approval', wait for admin, send pending notification
- If `null` → Check global `auto_approve_requests` setting
- If 'true' → Auto-approve (status: 'pending')
- Otherwise → Require approval (status: 'awaiting_approval')
- If 'true' → Auto-approve (status: 'pending', send approved notification)
- Otherwise → Require approval (status: 'awaiting_approval', send pending notification)
**When user creates request with pre-selected torrent (interactive search):**
- **Via POST /api/audiobooks/request-with-torrent** (book detail page):
1. Check approval requirements (same logic as above)
2. If approval needed → Set status to 'awaiting_approval', store torrent in `selectedTorrent`, send pending notification
3. If auto-approved → Set status to 'downloading', start download immediately, send approved notification
- **Via POST /api/requests/{id}/select-torrent** (existing request):
1. Check if request already in 'awaiting_approval' status → Block with 403 error
2. Check approval requirements based on CURRENT settings
3. If approval needed → Set status to 'awaiting_approval', store torrent in `selectedTorrent`, send pending notification
4. If auto-approved → Set status to 'downloading', start download immediately, send approved notification
**Admin approval actions:**
- **Approve** → Change status to 'pending', trigger search job
- **Deny** → Change status to 'denied', no further processing
- **Approve:**
- If request has `selectedTorrent` → Download that specific torrent (clear `selectedTorrent` field)
- If no `selectedTorrent` → Trigger automatic search job (status: 'pending')
- Send approved notification
- **Deny:** → Change status to 'denied', no further processing
## API Endpoints
### POST /api/audiobooks/request-with-torrent
Create request with pre-selected torrent (book detail page interactive search)
**Auth:** User or Admin
**Request:**
```json
{
"audiobook": { /* audiobook metadata */ },
"torrent": { /* selected torrent data */ }
}
```
**Approval Check:**
- Checks approval requirements
- If needed → Status 'awaiting_approval', stores torrent, sends pending notification
- If auto-approved → Status 'downloading', starts download, sends approved notification
**Response (awaiting approval):**
```json
{
"success": true,
"request": { /* request with status: 'awaiting_approval' */ },
"message": "Request submitted for admin approval"
}
```
**Response (auto-approved):**
```json
{
"success": true,
"request": { /* request with status: 'downloading' */ }
}
```
### POST /api/requests/[id]/select-torrent
Select torrent for existing request (request page interactive search)
**Auth:** User (owner) or Admin
**Request:**
```json
{
"torrent": { /* selected torrent data */ }
}
```
**Approval Check:**
- Blocks if already in 'awaiting_approval' status
- Re-checks approval requirements based on CURRENT settings
- If needed → Status 'awaiting_approval', stores torrent, sends pending notification
- If auto-approved → Status 'downloading', starts download, sends approved notification
**Response (awaiting approval):**
```json
{
"success": true,
"request": { /* request with status: 'awaiting_approval' */ },
"message": "Request submitted for admin approval"
}
```
**Response (auto-approved):**
```json
{
"success": true,
"request": { /* request with status: 'downloading' */ },
"message": "Torrent download initiated"
}
```
### GET /api/admin/requests/pending-approval
Fetch all requests with status 'awaiting_approval'
@@ -76,12 +163,31 @@ Approve or deny a specific request
}
```
**Response (approve):**
**Approval Logic:**
- If request has `selectedTorrent`:
- Downloads that specific torrent directly (status: 'downloading')
- Clears `selectedTorrent` field after use
- Message: "Request approved and download started with pre-selected torrent"
- If no `selectedTorrent`:
- Triggers automatic search job (status: 'pending')
- Message: "Request approved and search job triggered"
- Both send approved notification
**Response (approve with pre-selected torrent):**
```json
{
"success": true,
"message": "Request approved and download started with pre-selected torrent",
"request": { /* full request object with status: 'downloading' */ }
}
```
**Response (approve without pre-selected torrent):**
```json
{
"success": true,
"message": "Request approved and search job triggered",
"request": { /* full request object */ }
"request": { /* full request object with status: 'pending' */ }
}
```
@@ -90,7 +196,7 @@ Approve or deny a specific request
{
"success": true,
"message": "Request denied",
"request": { /* full request object */ }
"request": { /* full request object with status: 'denied' */ }
}
```
@@ -188,10 +294,28 @@ Update user (includes autoApproveRequests field)
- **denied** → Red badge with X icon
- All other statuses → Existing badge colors
## Security
**Interactive Search Approval Enforcement:**
- All interactive search flows (request-with-torrent, select-torrent) check approval requirements
- If approval needed, torrent is stored in `selectedTorrent` field and request enters 'awaiting_approval' status
- Admin sees exact torrent user selected when reviewing approval
- Upon approval, admin approves THAT specific torrent (no re-search)
**Settings Change Protection:**
- `select-torrent` endpoint re-checks approval requirements based on CURRENT settings
- Prevents bypass: User with auto-approve enabled creates request → Admin disables auto-approve → User tries to download
- If settings changed, torrent is stored and request enters approval queue
**Notification Timing:**
- Automatic search: Notification sent immediately on request creation
- Interactive search (auto-approved): Notification sent when torrent selected and download starts
- Interactive search (approval needed): Pending notification sent immediately, approved notification sent on admin approval
## Database Schema
### User Table
```
```prisma
autoApproveRequests: Boolean (nullable, default null)
- null: Use global setting
- true: Always auto-approve
@@ -199,17 +323,35 @@ autoApproveRequests: Boolean (nullable, default null)
```
### Request Table
```
```prisma
status: Enum (includes 'awaiting_approval', 'denied')
selectedTorrent: Json (nullable)
- Stores pre-selected torrent data from interactive search
- Set when approval needed, cleared after admin approval
- Contains: guid, title, size, seeders, indexer, downloadUrl, format, etc.
```
### Configuration Table
```
```prisma
key: 'auto_approve_requests'
value: 'true' | 'false' (string)
```
## Fixed Issues ✅
**1. BookDate Requests Bypass Approval System**
- Issue: Requests created through BookDate (right swipe) bypassed approval system entirely
- Security Impact: Critical - allowed users to bypass admin approval controls
- Cause: BookDate swipe route created requests with hardcoded 'pending' status, no approval checks, no notifications
- Fix: Implemented full approval logic in BookDate swipe route (same as POST /api/requests)
- Checks user.autoApproveRequests and global auto_approve_requests setting
- Sets correct status ('awaiting_approval' or 'pending')
- Sends appropriate notifications (request_pending_approval or request_approved)
- Only triggers search job if auto-approved
- Files updated: `src/app/api/bookdate/swipe/route.ts:124-217`, `tests/api/bookdate.routes.test.ts:470-648`
## Related
- [Admin Dashboard](../admin-dashboard.md) - Dashboard UI features
- [Database Schema](../backend/database.md) - User and Request tables
- [Settings Pages](../settings-pages.md) - Global settings management
- [BookDate Feature](../features/bookdate.md) - AI recommendations (Fixed Issues #9)
@@ -0,0 +1,182 @@
# Notification System
**Status:** ✅ Implemented | Extensible notification system with Discord 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:** Discord (webhooks), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
## Database Schema
```prisma
model NotificationBackend {
id String @id @default(uuid())
type String // 'discord' | '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_available | Plex/ABS scan completes | Audiobook available in library |
| request_error | Download/import fails | Request failed at any stage |
## 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
**Request Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available
- Includes user info in query (plexUsername)
**Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload
## Configuration Encryption
**Encrypted Values:**
- Discord: `webhookUrl`
- 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
**Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
- Fields: Title, Author, Requested By, Error (if applicable)
- Footer: Request ID
- Timestamp: Event time
**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 (4 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:**
```typescript
{
jobId?: string,
event: string,
requestId: string,
title: string,
author: string,
userName: string,
message?: string,
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?)`
## Extensibility
**Adding New Backend (e.g., Email):**
1. Add 'email' to NotificationBackendType enum
2. Create EmailConfig interface
3. Add encryption logic for smtpPassword
4. Implement sendEmail() method in NotificationService
5. Add email card to type selector (green "E" badge)
6. Add email form fields to modal
**Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum
2. Add to event labels in UI
3. Add trigger point in processor
4. Add message formatting in Discord/Pushover formatters
## Tech Stack
- Bull (job queue)
- Node.js crypto (AES-256-GCM encryption)
- Discord webhooks, Pushover API
- React (UI), Tailwind CSS (styling)
## Related
- [Job Queue System](jobs.md)
- [Config Encryption](config.md)
- [Settings Pages](../../settings-pages.md)
+24
View File
@@ -425,6 +425,30 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
- Added tests to verify 401 from external provider returns 400 to client
- Files updated: `src/app/api/bookdate/test-connection/route.ts:190-197,382-389`, `tests/api/bookdate-test-connection.routes.test.ts:254-294`
**9. BookDate Requests Bypass Approval System**
- Issue: Requests created through BookDate (right swipe) bypass the approval system entirely
- User Experience: "BookDate requests don't go through approval even when approval is required, and I don't get any notifications about them"
- Security Impact: Critical - allows users to bypass admin approval controls
- Cause: BookDate swipe route created requests directly without checking approval requirements
- `src/app/api/bookdate/swipe/route.ts:124-146` hardcoded status as 'pending'
- Did not check user.autoApproveRequests or global auto_approve_requests setting
- Did not send any notifications (pending approval or approved)
- Immediately triggered search job regardless of approval status
- Contrast with POST /api/requests which properly implements approval logic
- Fix: Implement full approval logic in BookDate swipe route (same as POST /api/requests)
- Fetch user with autoApproveRequests setting
- Check approval requirements: user setting → global setting → default (true)
- Set status: 'awaiting_approval' if approval needed, 'pending' if auto-approved
- Send appropriate notification: request_pending_approval or request_approved
- Only trigger search job if auto-approved (not if awaiting approval)
- Admins always auto-approve (role === 'admin')
- Files updated: `src/app/api/bookdate/swipe/route.ts:124-217`, `tests/api/bookdate.routes.test.ts:470-648`
- Tests added:
- Admin user auto-approves (status: 'pending', sends approved notification, triggers search)
- User with autoApproveRequests=false requires approval (status: 'awaiting_approval', sends pending notification, no search)
- User with autoApproveRequests=true auto-approves (status: 'pending', sends approved notification, triggers search)
- User with autoApproveRequests=null checks global setting
## Related
- Full requirements: [features/bookdate-prd.md](bookdate-prd.md)
+44 -17
View File
@@ -88,35 +88,54 @@ This prevents issues where category retains old save path after user changes `do
**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective.
**Example Scenario:**
- qBittorrent reports: `/remote/mnt/d/done/Audiobook.Name`
- ReadMeABook needs: `/downloads/Audiobook.Name`
- Mapping: Remote `/remote/mnt/d/done` Local `/downloads`
- qBittorrent on Windows expects: `F:\Docker\downloads\completed\books`
- ReadMeABook inside Docker sees: `/downloads`
- Mapping: Remote `F:\Docker\downloads\completed\books` Local `/downloads`
**Configuration:**
1. Admin Settings → Download Client → Enable Remote Path Mapping
2. Enter remote path (as reported by qBittorrent)
3. Enter local path (accessible to ReadMeABook)
2. Enter remote path (as qBittorrent sees it, e.g., `F:\Docker\downloads\completed\books`)
3. Enter local path (as RMAB sees it, e.g., `/downloads`)
4. Test connection validates local path exists
5. Save settings
**Bidirectional Path Mapping:**
**1. Outgoing (RMAB → qBittorrent):** When adding torrents
- RMAB's download path: `/downloads`
- Translated to qBit's path: `F:\Docker\downloads\completed\books`
- Applied in `qbittorrent.service.ts` via `PathMapper.reverseTransform()`
- Ensures qBittorrent knows where to save files
**2. Incoming (qBittorrent → RMAB):** When processing completed downloads
- qBit reports: `F:\Docker\downloads\completed\books\Audiobook.Name`
- Translated to RMAB's path: `/downloads/Audiobook.Name`
- Applied in `monitor-download.processor.ts` via `PathMapper.transform()`
- Applied in `retry-failed-imports.processor.ts` for failed imports
- Ensures RMAB can find and organize files
**Implementation:**
- `PathMapper` utility (`src/lib/utils/path-mapper.ts`) handles transformation
- Applied in `monitor-download.processor.ts` when download completes
- Applied in `retry-failed-imports.processor.ts` for failed imports
- `transform()`: Remote → Local (qBit → RMAB)
- `reverseTransform()`: Local → Remote (RMAB → qBit)
- Uses simple prefix replacement with path normalization
- Graceful fallback: if path doesn't match remote prefix, returns unchanged
- Preserves Windows backslashes when translating to Windows paths
- Graceful fallback: if path doesn't match prefix, returns unchanged
**Path Transformation Examples:**
**Path Transformation:**
```typescript
// Input from qBittorrent
qbPath = "/remote/mnt/d/done/Audiobook.Name"
// Config
remotePath = "/remote/mnt/d/done"
// Outgoing: RMAB → qBittorrent (when adding torrent)
localPath = "/downloads"
config = { remotePath: "F:\\Docker\\downloads\\completed\\books", localPath: "/downloads" }
remotePath = PathMapper.reverseTransform(localPath, config)
// Result: "F:\Docker\downloads\completed\books"
// Output (used for file organization)
organizePath = "/downloads/Audiobook.Name"
// Incoming: qBittorrent → RMAB (when processing completion)
qbPath = "F:\\Docker\\downloads\\completed\\books\\Audiobook.Name"
config = { remotePath: "F:\\Docker\\downloads\\completed\\books", localPath: "/downloads" }
organizePath = PathMapper.transform(qbPath, config)
// Result: "/downloads/Audiobook.Name"
```
**Validation:**
@@ -126,9 +145,10 @@ organizePath = "/downloads/Audiobook.Name"
**Behavior:**
- Mapping only applies when enabled
- If path doesn't start with remote prefix, returns original (logs warning)
- If path doesn't start with expected prefix, returns original (logs warning)
- Path normalization handles trailing slashes, backslashes, redundant separators
- Works with both `content_path` and constructed `save_path + name`
- Preserves native path separators (important for Windows)
## Data Models
@@ -188,6 +208,13 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
- Applied to axios client instance and all standalone requests
- Works transparently with or without reverse proxy
- Compatible with popular seedbox providers (seedit4.me, etc.)
**14. Remote path mapping not applied when adding torrents** - When qBittorrent runs locally (e.g., Windows) and RMAB runs in Docker, savepath sent to qBittorrent was not translated. qBittorrent received `/downloads` (RMAB's path) but expected `F:\Docker\downloads\completed\books` (Windows path), causing "Invalid path" errors. Fixed by:
- Added `PathMapper.reverseTransform()` for bidirectional path mapping (local → remote)
- Applied in `qbittorrent.service.ts` when setting savepath for torrents
- Preserves Windows backslashes when translating to Windows paths
- Path mapping now works in both directions: outgoing (RMAB → qBit) and incoming (qBit → RMAB)
- Service constructor accepts `PathMappingConfig` parameter
- Singleton loads path mapping config from database
## Tech Stack
+36
View File
@@ -70,6 +70,7 @@ src/app/admin/settings/
4. **Download Client** - Type, URL, credentials (masked)
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
7. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
## Audible Region
@@ -300,3 +301,38 @@ src/app/admin/settings/
- Allow saving indexer config changes without re-testing connection
- Button text adapts: "Test Connection" vs "Refresh Indexers"
- Behavior: Natural workflow - see current settings, modify indexers, save immediately
## Notifications
**Purpose:** Configure notification backends to receive alerts for audiobook request events.
**Configuration:**
- Multiple backends per type (Discord, Pushover)
- Per-backend event subscriptions (4 events)
- Encrypted sensitive values (webhook URLs, API keys)
- Enable/disable toggle per backend
**UI (NotificationsTab):**
- Type selector cards: Discord (indigo "D"), Pushover (blue "P")
- Grid layout for configured backends (3 columns)
- Card shows: type icon, name, enabled status, event count
- Edit/delete actions per card
**Modal (NotificationConfigModal):**
- Type-specific forms (Discord: webhook/username/avatar, Pushover: keys/priority)
- Event subscription checkboxes (4 events)
- Enable/disable toggle
- Test button (sends sample notification)
- Password masking for sensitive values
**Event Types:**
- Request Pending Approval - Admin approval required
- Request Approved - Approved (manual or auto)
- Request Available - Available in library
- Request Error - Failed at any stage
**Validation:**
- Name required
- Discord: webhook URL required
- Pushover: user key + app token required
- At least one event selected