mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
@@ -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';
|
||||
Reference in New Issue
Block a user