Files
ReadMeABook/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx
T
kikootwo 20c8fb0898 Add reported-issues, Goodreads sync & notifs
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
2026-02-11 16:49:55 -05:00

467 lines
18 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api';
import { EVENT_LABELS } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('NotificationsTab');
interface ProviderConfigField {
name: string;
label: string;
type: 'text' | 'password' | 'select' | 'number';
required: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: { label: string; value: string | number }[];
}
interface ProviderMetadata {
type: string;
displayName: string;
description: string;
iconLabel: string;
iconColor: string;
configFields: ProviderConfigField[];
}
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 eventLabels: Record<string, string> = EVENT_LABELS;
export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]);
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
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();
fetchProviderMetadata();
}, []);
const fetchProviderMetadata = async () => {
try {
const response = await fetchWithAuth('/api/admin/notifications/providers');
if (response.ok) {
const data = await response.json();
if (data.success) {
setProviderMetadata(data.providers);
}
}
} catch (error) {
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
}
};
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 getMetadataForType = (type: string): ProviderMetadata | undefined => {
return providerMetadata.find((p) => p.type === type);
};
const openAddModal = (type: string) => {
const meta = getMetadataForType(type);
const defaultConfig: Record<string, any> = {};
if (meta) {
for (const field of meta.configFields) {
defaultConfig[field.name] = field.defaultValue ?? '';
}
}
setModalState({ isOpen: true, mode: 'add', selectedType: type });
setFormData({
name: `${meta?.displayName ?? type} Notifications`,
config: defaultConfig,
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);
// In edit mode, use backend ID to test with real config (masked values won't work)
// In add mode, use the form config directly
const testPayload = modalState.mode === 'edit' && modalState.backend
? { backendId: modalState.backend.id }
: { type: modalState.selectedType, config: formData.config };
const response = await fetchWithAuth('/api/admin/notifications/test', {
method: 'POST',
body: JSON.stringify(testPayload),
});
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) });
}
};
const renderConfigField = (field: ProviderConfigField) => {
if (field.type === 'select' && field.options) {
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : ''}
</label>
<select
value={formData.config[field.name] ?? field.defaultValue ?? ''}
onChange={(e) => {
const value = field.options?.some((o) => typeof o.value === 'number')
? Number(e.target.value)
: e.target.value;
setFormData({ ...formData, config: { ...formData.config, [field.name]: 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"
>
{field.options.map((opt) => (
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
}
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
</label>
<input
type={field.type === 'password' ? 'password' : 'text'}
value={formData.config[field.name] ?? ''}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.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={field.placeholder}
/>
</div>
);
};
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
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-3 gap-4">
{providerMetadata.map((meta) => (
<button
key={meta.type}
onClick={() => openAddModal(meta.type)}
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 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
{meta.iconLabel}
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</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) => {
const meta = getMetadataForType(backend.type);
return (
<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 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
{meta?.iconLabel ?? 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">{meta?.displayName ?? 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'} {currentMeta?.displayName ?? modalState.selectedType} 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>
{/* Dynamic Config Fields */}
{currentMeta?.configFields.map((field) => renderConfigField(field))}
{/* 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>
);
}