mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -212,4 +212,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'paths' as const, label: 'Paths', icon: '📁' },
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||
];
|
||||
|
||||
@@ -226,4 +226,4 @@ export interface BookDateModel {
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate';
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DownloadTab } from './tabs/DownloadTab/DownloadTab';
|
||||
import { PathsTab } from './tabs/PathsTab/PathsTab';
|
||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||
|
||||
// Types and Helpers
|
||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||
@@ -328,8 +329,11 @@ export default function AdminSettings() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
const logger = RMABLogger.create('NotificationsTab');
|
||||
|
||||
interface NotificationBackend {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
events: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
isOpen: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
selectedType?: string;
|
||||
backend?: NotificationBackend;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
discord: 'bg-indigo-500',
|
||||
pushover: 'bg-blue-500',
|
||||
email: 'bg-green-500',
|
||||
slack: 'bg-purple-500',
|
||||
telegram: 'bg-sky-500',
|
||||
webhook: 'bg-gray-500',
|
||||
};
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
request_pending_approval: 'Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
isOpen: false,
|
||||
mode: 'add',
|
||||
});
|
||||
const [formData, setFormData] = useState<any>({
|
||||
name: '',
|
||||
config: {},
|
||||
events: ['request_available', 'request_error'],
|
||||
enabled: true,
|
||||
});
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackends();
|
||||
}, []);
|
||||
|
||||
const fetchBackends = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchWithAuth('/api/admin/notifications');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setBackends(data.backends);
|
||||
} else {
|
||||
logger.error('Failed to fetch backends', { error: data.error });
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to fetch backends', { status: response.status });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch backends', { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddModal = (type: string) => {
|
||||
setModalState({ isOpen: true, mode: 'add', selectedType: type });
|
||||
setFormData({
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
|
||||
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
|
||||
events: ['request_available', 'request_error'],
|
||||
enabled: true,
|
||||
});
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const openEditModal = (backend: NotificationBackend) => {
|
||||
setModalState({ isOpen: true, mode: 'edit', selectedType: backend.type, backend });
|
||||
setFormData({
|
||||
name: backend.name,
|
||||
config: backend.config,
|
||||
events: backend.events,
|
||||
enabled: backend.enabled,
|
||||
});
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState({ isOpen: false, mode: 'add' });
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!modalState.selectedType) return;
|
||||
|
||||
try {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const response = await fetchWithAuth('/api/admin/notifications/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: modalState.selectedType,
|
||||
config: formData.config,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setTestResult({ success: true, message: 'Test notification sent successfully!' });
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.message || 'Failed to send test notification' });
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({ success: false, message: error instanceof Error ? error.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modalState.selectedType) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
const url = modalState.mode === 'add' ? '/api/admin/notifications' : `/api/admin/notifications/${modalState.backend?.id}`;
|
||||
const method = modalState.mode === 'add' ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetchWithAuth(url, {
|
||||
method,
|
||||
body: JSON.stringify({
|
||||
type: modalState.selectedType,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
await fetchBackends();
|
||||
closeModal();
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.message || 'Failed to save backend' });
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({ success: false, message: error instanceof Error ? error.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this notification backend?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await fetchBackends();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete backend', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure notification backends to receive alerts for audiobook request events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Type Selector */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => openAddModal('discord')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
D
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAddModal('pushover')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
P
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Backends */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Configured Backends</h3>
|
||||
{loading ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
) : backends.length === 0 ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{backends.map((backend) => (
|
||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||
{backend.type.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(backend)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backend.id)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{modalState.isOpen && modalState.selectedType && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., Discord - Admins"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{modalState.selectedType === 'discord' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.webhookUrl}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.username}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="ReadMeABook"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modalState.selectedType === 'pushover' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.userKey}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover user key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.appToken}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover app token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
||||
<select
|
||||
value={formData.config.priority}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="-2">Lowest</option>
|
||||
<option value="-1">Low</option>
|
||||
<option value="0">Normal</option>
|
||||
<option value="1">High</option>
|
||||
<option value="2">Emergency</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subscribe to Events *</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(eventLabels).map(([event, label]) => (
|
||||
<label key={event} className="flex items-center space-x-2 p-3 border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.events.includes(event)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setFormData({ ...formData, events: [...formData.events, event] });
|
||||
} else {
|
||||
setFormData({ ...formData, events: formData.events.filter((e: string) => e !== event) });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-white">Enable this notification backend</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`p-3 rounded-lg ${testResult.success ? 'bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200' : 'bg-red-100 dark:bg-red-900 border border-red-300 dark:border-red-700 text-red-800 dark:text-red-200'}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{isTesting ? 'Testing...' : 'Send Test'}
|
||||
</button>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : (modalState.mode === 'add' ? 'Add Backend' : 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NotificationsTab } from './NotificationsTab';
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Component: Notification Backend Individual API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Id');
|
||||
|
||||
const UpdateBackendSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications/[id]
|
||||
* Get single notification backend (sensitive values masked)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Mask sensitive config values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/notifications/[id]
|
||||
* Update notification backend
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const updates = UpdateBackendSchema.parse(body);
|
||||
|
||||
// Get existing backend
|
||||
const existing = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Handle config updates (preserve masked values, encrypt new values)
|
||||
let finalConfig = existing.config;
|
||||
if (updates.config) {
|
||||
const existingConfig = existing.config as any;
|
||||
const updatedConfig = updates.config as any;
|
||||
|
||||
// Check if masked values need to be preserved
|
||||
Object.keys(updatedConfig).forEach((key) => {
|
||||
if (updatedConfig[key] === '••••••••') {
|
||||
// Preserve existing encrypted value
|
||||
updatedConfig[key] = existingConfig[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Encrypt new/changed values
|
||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
||||
}
|
||||
|
||||
// Update backend
|
||||
const updateData: any = {};
|
||||
if (updates.name) updateData.name = updates.name;
|
||||
if (updates.config) updateData.config = finalConfig;
|
||||
if (updates.events) updateData.events = updates.events;
|
||||
if (updates.enabled !== undefined) updateData.enabled = updates.enabled;
|
||||
|
||||
const updated = await prisma.notificationBackend.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(`Updated notification backend: ${updated.name}`, {
|
||||
backendId: id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
// Return with masked values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...updated,
|
||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'UpdateError',
|
||||
message: 'Failed to update notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/notifications/[id]
|
||||
* Delete notification backend
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Check if backend exists
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete backend
|
||||
await prisma.notificationBackend.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
logger.info(`Deleted notification backend: ${backend.name}`, {
|
||||
backendId: id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notification backend deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DeleteError',
|
||||
message: 'Failed to delete notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Component: Notification Backend API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications');
|
||||
|
||||
const CreateBackendSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
name: z.string().min(1),
|
||||
config: z.record(z.any()),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications
|
||||
* List all notification backends (sensitive values masked)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Mask sensitive config values
|
||||
const maskedBackends = backends.map((backend) => ({
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backends: maskedBackends,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch notification backends', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch notification backends',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/notifications
|
||||
* Create new notification backend
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, name, config, events, enabled } = CreateBackendSchema.parse(body);
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Encrypt sensitive config values
|
||||
const encryptedConfig = notificationService.encryptConfig(type, config);
|
||||
|
||||
// Create backend
|
||||
const backend = await prisma.notificationBackend.create({
|
||||
data: {
|
||||
type,
|
||||
name,
|
||||
config: encryptedConfig,
|
||||
events,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created notification backend: ${name} (${type})`, {
|
||||
backendId: backend.id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
// Return with masked values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(type, backend.config),
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'CreateError',
|
||||
message: 'Failed to create notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Component: Notification Test API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/notifications/test
|
||||
* Test notification with provided config (synchronous)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, config } = TestNotificationSchema.parse(body);
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Encrypt config values
|
||||
const encryptedConfig = notificationService.encryptConfig(type, config);
|
||||
|
||||
// Create test payload
|
||||
const testPayload: NotificationPayload = {
|
||||
event: 'request_available',
|
||||
requestId: 'test-request-id',
|
||||
title: "The Hitchhiker's Guide to the Galaxy",
|
||||
author: 'Douglas Adams',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Send test notification synchronously (not via job queue)
|
||||
try {
|
||||
// Call sendToBackend directly
|
||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
||||
|
||||
logger.info(`Test notification sent successfully for ${type}`, {
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Test notification sent successfully',
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.error(`Test notification failed for ${type}`, {
|
||||
error: notificationError instanceof Error ? notificationError.message : String(notificationError),
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NotificationError',
|
||||
message: notificationError instanceof Error ? notificationError.message : 'Failed to send test notification',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to test notification', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'TestError',
|
||||
message: 'Failed to test notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -75,42 +75,123 @@ export async function POST(
|
||||
|
||||
// Update request based on action
|
||||
if (action === 'approve') {
|
||||
// Approve: Change status to 'pending' and trigger search job
|
||||
const updatedRequest = await prisma.request.update({
|
||||
where: { id },
|
||||
data: { status: 'pending' },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Check if request has a pre-selected torrent (from interactive search)
|
||||
if (existingRequest.selectedTorrent) {
|
||||
// User pre-selected a specific torrent - download that torrent directly
|
||||
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
// Trigger download job with pre-selected torrent
|
||||
await jobQueue.addDownloadJob(
|
||||
existingRequest.id,
|
||||
{
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
},
|
||||
existingRequest.selectedTorrent as any
|
||||
);
|
||||
|
||||
// Update status to 'downloading' and clear selectedTorrent
|
||||
const updatedRequest = await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
selectedTorrent: null as any, // Clear after use
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(updatedRequest.id, {
|
||||
id: updatedRequest.audiobook.id,
|
||||
title: updatedRequest.audiobook.title,
|
||||
author: updatedRequest.audiobook.author,
|
||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
// Send notification for manual approval
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
updatedRequest.id,
|
||||
existingRequest.audiobook.title,
|
||||
existingRequest.audiobook.author,
|
||||
existingRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: updatedRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: existingRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and search job triggered',
|
||||
request: updatedRequest,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and download started with pre-selected torrent',
|
||||
request: updatedRequest,
|
||||
});
|
||||
} else {
|
||||
// No pre-selected torrent - use automatic search
|
||||
logger.info(`Request ${id} using automatic search`, {
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
const updatedRequest = await prisma.request.update({
|
||||
where: { id },
|
||||
data: { status: 'pending' },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
await jobQueue.addSearchJob(updatedRequest.id, {
|
||||
id: updatedRequest.audiobook.id,
|
||||
title: updatedRequest.audiobook.title,
|
||||
author: updatedRequest.audiobook.author,
|
||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
// Send notification for manual approval
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
updatedRequest.id,
|
||||
updatedRequest.audiobook.title,
|
||||
updatedRequest.audiobook.author,
|
||||
updatedRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: updatedRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and search job triggered',
|
||||
request: updatedRequest,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Deny: Change status to 'denied'
|
||||
const updatedRequest = await prisma.request.update({
|
||||
|
||||
@@ -193,43 +193,141 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Create request with downloading status
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
// Check if request needs approval
|
||||
let needsApproval = false;
|
||||
|
||||
// Fetch user with autoApproveRequests setting
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue download job with the selected torrent
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'UserNotFound', message: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if approval is needed
|
||||
if (user.role === 'admin') {
|
||||
// Admins always auto-approve
|
||||
needsApproval = false;
|
||||
} else {
|
||||
// Check user's personal setting first
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
// User setting is null, check global setting
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addDownloadJob(
|
||||
newRequest.id,
|
||||
{
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
},
|
||||
torrent
|
||||
);
|
||||
|
||||
logger.info(`Queued download monitor job for request ${newRequest.id}`);
|
||||
if (needsApproval) {
|
||||
// Create request with awaiting_approval status and store selected torrent
|
||||
logger.info('Request requires approval, storing selected torrent', { userId: req.user.id });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: newRequest,
|
||||
}, { status: 201 });
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
selectedTorrent: torrent as any, // Store the selected torrent for later
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send pending approval notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${newRequest.id} created, awaiting admin approval`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: newRequest,
|
||||
message: 'Request submitted for admin approval',
|
||||
}, { status: 201 });
|
||||
} else {
|
||||
// Auto-approved - create request with downloading status and start download
|
||||
logger.info('Request auto-approved, starting download', { userId: req.user.id });
|
||||
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue download job with the selected torrent
|
||||
await jobQueue.addDownloadJob(
|
||||
newRequest.id,
|
||||
{
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
},
|
||||
torrent
|
||||
);
|
||||
|
||||
// Send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${newRequest.id} auto-approved and download queued`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: newRequest,
|
||||
}, { status: 201 });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create request with torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
|
||||
@@ -122,28 +122,97 @@ async function handler(req: AuthenticatedRequest) {
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
// Check if request needs approval (same logic as POST /api/requests)
|
||||
let needsApproval = false;
|
||||
|
||||
// Fetch user with autoApproveRequests setting
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.error('User not found during request creation');
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Determine if approval is needed
|
||||
if (user.role === 'admin') {
|
||||
// Admins always auto-approve
|
||||
needsApproval = false;
|
||||
} else {
|
||||
// Check user's personal setting first
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
// User setting is null, check global setting
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine initial status
|
||||
const initialStatus = needsApproval ? 'awaiting_approval' : 'pending';
|
||||
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
status: 'pending',
|
||||
status: initialStatus,
|
||||
priority: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created request for "${recommendation.title}"`);
|
||||
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
|
||||
|
||||
// Trigger search job (same as regular request creation)
|
||||
// Import job queue service
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search job for request ${newRequest.id}`);
|
||||
// Send notification based on approval status
|
||||
if (needsApproval) {
|
||||
// Request needs approval - send pending notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
newRequest.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
} else {
|
||||
// Request was auto-approved - send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
newRequest.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger search job only if auto-approved
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search job for request ${newRequest.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -63,6 +63,14 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is awaiting approval
|
||||
if (requestRecord.status === 'awaiting_approval') {
|
||||
return NextResponse.json(
|
||||
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get enabled indexers from configuration
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
@@ -62,10 +62,96 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is awaiting approval
|
||||
if (requestRecord.status === 'awaiting_approval') {
|
||||
return NextResponse.json(
|
||||
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot download torrents until it is approved.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Re-check if approval is needed based on CURRENT settings (security: settings may have changed)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'UserNotFound', message: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let needsApproval = false;
|
||||
|
||||
// Determine if approval is needed (same logic as request creation)
|
||||
if (user.role === 'admin') {
|
||||
// Admins always auto-approve
|
||||
needsApproval = false;
|
||||
} else {
|
||||
// Check user's personal setting first
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
// User setting is null, check global setting
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// If approval is now needed, store torrent and wait for approval
|
||||
if (needsApproval) {
|
||||
logger.info(`Torrent selection requires approval`, { requestId: id, userId: req.user.id });
|
||||
|
||||
const updated = await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: torrent as any, // Store the selected torrent
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Send pending approval notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
updated.id,
|
||||
requestRecord.audiobook.title,
|
||||
requestRecord.audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${id} stored selected torrent and awaits admin approval`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: updated,
|
||||
message: 'Request submitted for admin approval',
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-approved - start download immediately
|
||||
logger.info(`User selected torrent: ${torrent.title}`, { requestId: id });
|
||||
|
||||
// Trigger download job with the selected torrent
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addDownloadJob(
|
||||
id,
|
||||
{
|
||||
@@ -76,6 +162,17 @@ export async function POST(
|
||||
torrent
|
||||
);
|
||||
|
||||
// Send approved notification (user has now committed to downloading)
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
id,
|
||||
requestRecord.audiobook.title,
|
||||
requestRecord.audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Update request status
|
||||
const updated = await prisma.request.update({
|
||||
where: { id },
|
||||
|
||||
@@ -252,9 +252,35 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Send notification based on approval status
|
||||
if (initialStatus === 'awaiting_approval') {
|
||||
// Request needs approval - send pending notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
newRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
} else {
|
||||
// Request was auto-approved (either automatic or interactive search) - send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
newRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger search job only if not skipped and not awaiting approval
|
||||
if (shouldTriggerSearch) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
|
||||
@@ -8,6 +8,7 @@ import https from 'https';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
@@ -87,6 +88,7 @@ export class QBittorrentService {
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@@ -94,7 +96,8 @@ export class QBittorrentService {
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
@@ -102,6 +105,7 @@ export class QBittorrentService {
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
@@ -270,10 +274,14 @@ export class QBittorrentService {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
}
|
||||
|
||||
// Apply reverse path mapping (local → remote) to savepath
|
||||
const localSavePath = options?.savePath || this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Upload via 'urls' parameter
|
||||
const form = new URLSearchParams({
|
||||
urls: magnetUrl,
|
||||
savepath: options?.savePath || this.defaultSavePath,
|
||||
savepath: remoteSavePath,
|
||||
category,
|
||||
paused: options?.paused ? 'true' : 'false',
|
||||
sequentialDownload: (options?.sequentialDownload !== false).toString(),
|
||||
@@ -408,6 +416,10 @@ export class QBittorrentService {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
}
|
||||
|
||||
// Apply reverse path mapping (local → remote) to savepath
|
||||
const localSavePath = options?.savePath || this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Upload .torrent file content via multipart/form-data
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -416,7 +428,7 @@ export class QBittorrentService {
|
||||
filename,
|
||||
contentType: 'application/x-bittorrent',
|
||||
});
|
||||
formData.append('savepath', options?.savePath || this.defaultSavePath);
|
||||
formData.append('savepath', remoteSavePath);
|
||||
formData.append('category', category);
|
||||
formData.append('paused', options?.paused ? 'true' : 'false');
|
||||
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
|
||||
@@ -996,6 +1008,9 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
'download_client_password',
|
||||
'download_dir',
|
||||
'download_client_disable_ssl_verify',
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
logger.info('[QBittorrent] Config loaded:', {
|
||||
@@ -1004,6 +1019,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
hasPassword: !!config.download_client_password,
|
||||
hasPath: !!config.download_dir,
|
||||
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
|
||||
pathMappingEnabled: config.download_client_remote_path_mapping_enabled === 'true',
|
||||
});
|
||||
|
||||
// Validate all required fields are present (no env var fallback)
|
||||
@@ -1035,6 +1051,13 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
const savePath = config.download_dir as string;
|
||||
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
|
||||
|
||||
// Path mapping configuration
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
enabled: config.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: config.download_client_remote_path || '',
|
||||
localPath: config.download_client_local_path || '',
|
||||
};
|
||||
|
||||
logger.info('[QBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
url,
|
||||
@@ -1042,7 +1065,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
password,
|
||||
savePath,
|
||||
'readmeabook',
|
||||
disableSSLVerify
|
||||
disableSSLVerify,
|
||||
pathMappingConfig
|
||||
);
|
||||
|
||||
// Test connection
|
||||
|
||||
@@ -196,12 +196,14 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
} else if (progress.state === 'failed') {
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
const errorMessage = 'Download failed in qBittorrent';
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: 'Download failed in qBittorrent',
|
||||
errorMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -211,10 +213,33 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: 'Download failed in qBittorrent',
|
||||
downloadError: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
errorMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
completed: true,
|
||||
@@ -266,14 +291,38 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
} else {
|
||||
// Permanent error - mark request as failed immediately
|
||||
const failureMessage = errorMessage || 'Monitor download failed';
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: errorMessage || 'Monitor download failed',
|
||||
errorMessage: failureMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
failureMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rethrow to trigger Bull's retry mechanism
|
||||
|
||||
@@ -253,16 +253,41 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
// Max retries exceeded - move to warn status
|
||||
logger.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
|
||||
|
||||
const warnMessage = `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`;
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'warn',
|
||||
importAttempts: newAttempts,
|
||||
errorMessage: `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`,
|
||||
errorMessage: warnMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
warnMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Max import retries exceeded, manual intervention required',
|
||||
@@ -282,6 +307,29 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
errorMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,14 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
@@ -237,6 +244,19 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification that audiobook is now available
|
||||
const { getJobQueueService } = await import('../services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
matchedDownloads++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
|
||||
@@ -366,7 +366,14 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100, // Increased from 50 to handle more eligible requests
|
||||
});
|
||||
|
||||
@@ -423,6 +430,19 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification that audiobook is now available
|
||||
const { getJobQueueService } = await import('../services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
matchedCount++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Send Notification Job Processor
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
* Processes notification jobs by calling NotificationService to send alerts
|
||||
* to all enabled backends subscribed to the event.
|
||||
*/
|
||||
|
||||
import { getNotificationService } from '../services/notification.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SendNotificationPayload {
|
||||
jobId?: string;
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process send notification job
|
||||
* Calls NotificationService to send notifications to all enabled backends
|
||||
*/
|
||||
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
|
||||
const { event, requestId, title, author, userName, message, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'SendNotification');
|
||||
|
||||
logger.info(`Processing notification: ${event}`, { requestId });
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.sendNotification({
|
||||
event,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`Notification processed: ${event}`, { requestId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to process notification', {
|
||||
event,
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ export type JobType =
|
||||
| 'retry_missing_torrents'
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds';
|
||||
| 'monitor_rss_feeds'
|
||||
| 'send_notification';
|
||||
|
||||
export interface JobPayload {
|
||||
jobId?: string; // Database job ID (added automatically by addJob)
|
||||
@@ -102,6 +103,16 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SendNotificationPayload extends JobPayload {
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
waiting: number;
|
||||
active: number;
|
||||
@@ -298,6 +309,12 @@ export class JobQueueService {
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
return await processSendNotification(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -790,6 +807,35 @@ export class JobQueueService {
|
||||
this.redis.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add notification job
|
||||
*/
|
||||
async addNotificationJob(
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error',
|
||||
requestId: string,
|
||||
title: string,
|
||||
author: string,
|
||||
userName: string,
|
||||
message?: string
|
||||
): Promise<string> {
|
||||
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
|
||||
return await this.addJob(
|
||||
'send_notification',
|
||||
{
|
||||
event,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
} as SendNotificationPayload,
|
||||
{
|
||||
priority: 5, // Medium priority
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a repeatable job with cron schedule
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend types
|
||||
export type NotificationBackendType =
|
||||
| 'discord'
|
||||
| 'pushover'
|
||||
| 'email'
|
||||
| 'slack'
|
||||
| 'telegram'
|
||||
| 'webhook';
|
||||
|
||||
// Config interfaces
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type NotificationConfig = DiscordConfig | PushoverConfig;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
// Pushover priorities
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific sender
|
||||
*/
|
||||
private async sendToBackend(
|
||||
type: NotificationBackendType,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
// Decrypt config
|
||||
const decryptedConfig = this.decryptConfig(config);
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
|
||||
case 'pushover':
|
||||
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
|
||||
default:
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Discord webhook notification
|
||||
*/
|
||||
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
|
||||
const embed = this.formatDiscordEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: config.username || 'ReadMeABook',
|
||||
avatar_url: config.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pushover notification
|
||||
*/
|
||||
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
|
||||
const { title, message } = this.formatPushoverMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: config.appToken,
|
||||
user: config.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(config.device && { device: config.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Discord rich embed
|
||||
*/
|
||||
private formatDiscordEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Pushover message
|
||||
*/
|
||||
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(config: any): any {
|
||||
const decrypted = { ...config };
|
||||
|
||||
// Discord: decrypt webhookUrl
|
||||
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
|
||||
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
|
||||
}
|
||||
|
||||
// Pushover: decrypt userKey and appToken
|
||||
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
|
||||
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
|
||||
}
|
||||
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
|
||||
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: NotificationBackendType, config: any): any {
|
||||
const encrypted = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
|
||||
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
|
||||
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
|
||||
}
|
||||
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
|
||||
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: NotificationBackendType, config: any): any {
|
||||
const masked = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (masked.webhookUrl) {
|
||||
masked.webhookUrl = '••••••••';
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (masked.userKey) {
|
||||
masked.userKey = '••••••••';
|
||||
}
|
||||
if (masked.appToken) {
|
||||
masked.appToken = '••••••••';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -67,6 +67,66 @@ export class PathMapper {
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse transforms a local path to qBittorrent remote path (local-to-remote mapping)
|
||||
*
|
||||
* Example:
|
||||
* Local path: /downloads/Audiobook.Name
|
||||
* Config: { enabled: true, remotePath: 'F:\\Docker\\downloads\\completed\\books', localPath: '/downloads' }
|
||||
* Returns: F:\Docker\downloads\completed\books\Audiobook.Name
|
||||
*
|
||||
* @param localPath - Path from ReadMeABook's perspective (inside Docker)
|
||||
* @param config - Path mapping configuration
|
||||
* @returns Transformed path for qBittorrent (or original if mapping disabled/no match)
|
||||
*/
|
||||
static reverseTransform(localPath: string, config: PathMappingConfig): string {
|
||||
// 1. If mapping disabled, return original
|
||||
if (!config.enabled) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 2. Handle empty paths
|
||||
if (!localPath || !config.remotePath || !config.localPath) {
|
||||
logger.warn('Empty path or config, returning original');
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 3. Normalize paths
|
||||
const normalizedRemote = this.normalizePath(config.remotePath);
|
||||
const normalizedLocal = this.normalizePath(config.localPath);
|
||||
const normalizedLocalPath = this.normalizePath(localPath);
|
||||
|
||||
// 4. Check if local path starts with local prefix
|
||||
if (!normalizedLocalPath.startsWith(normalizedLocal)) {
|
||||
logger.warn(
|
||||
`Path "${localPath}" does not start with local path "${config.localPath}". ` +
|
||||
`Returning original path unchanged.`
|
||||
);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 5. Replace local prefix with remote prefix
|
||||
const relativePath = normalizedLocalPath.substring(normalizedLocal.length);
|
||||
|
||||
// For remote path, preserve original path separators (important for Windows)
|
||||
// Use the original remote path's separators instead of normalizing
|
||||
const remoteSeparator = config.remotePath.includes('\\') ? '\\' : '/';
|
||||
const remotePathNormalized = config.remotePath.replace(/[/\\]+$/, ''); // Remove trailing slashes
|
||||
|
||||
// Build the final path with remote separators
|
||||
let transformedPath: string;
|
||||
if (relativePath) {
|
||||
// Convert forward slashes to remote separator
|
||||
const relativeWithRemoteSep = relativePath.replace(/^[/\\]+/, '').replace(/\//g, remoteSeparator);
|
||||
transformedPath = remotePathNormalized + remoteSeparator + relativeWithRemoteSep;
|
||||
} else {
|
||||
transformedPath = remotePathNormalized;
|
||||
}
|
||||
|
||||
logger.info(`Reverse transformed "${localPath}" to "${transformedPath}"`);
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates path mapping configuration
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user