mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Component: Admin Reported Issues Section
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*
|
||||
* Displays open reported issues on the admin dashboard.
|
||||
* Allows dismiss or search-for-replacement actions.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { fetchJSON } from '@/lib/utils/api';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
interface ReportedIssue {
|
||||
id: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl: string | null;
|
||||
audibleAsin: string | null;
|
||||
};
|
||||
reporter: {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportedIssuesSectionProps {
|
||||
issues: ReportedIssue[];
|
||||
}
|
||||
|
||||
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
|
||||
|
||||
const handleDismiss = async (issueId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'dismiss' }),
|
||||
});
|
||||
|
||||
toast.success('Issue dismissed');
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceSuccess = async () => {
|
||||
toast.success('Replacement download started');
|
||||
setReplaceIssue(null);
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Reported Issues
|
||||
</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Issues Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{issues.map((issue) => {
|
||||
const isLoading = loadingStates[issue.id] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* Card Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{issue.audiobook.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{issue.audiobook.author}
|
||||
</p>
|
||||
|
||||
{/* Reporter */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{issue.reporter.avatarUrl ? (
|
||||
<img
|
||||
src={issue.reporter.avatarUrl}
|
||||
alt={issue.reporter.plexUsername}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{issue.reporter.plexUsername}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
|
||||
{issue.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDismiss(issue.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Dismiss</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setReplaceIssue(issue)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Replace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal for Replacement */}
|
||||
{replaceIssue && createPortal(
|
||||
<div className="fixed inset-0 z-[60]">
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={!!replaceIssue}
|
||||
onClose={() => setReplaceIssue(null)}
|
||||
onSuccess={handleReplaceSuccess}
|
||||
audiobook={{
|
||||
title: replaceIssue.audiobook.title,
|
||||
author: replaceIssue.audiobook.author,
|
||||
}}
|
||||
asin={replaceIssue.audiobook.audibleAsin || undefined}
|
||||
replaceIssueId={replaceIssue.id}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
|
||||
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const { data: reportedIssuesData } = useSWR(
|
||||
'/api/admin/reported-issues',
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: settingsData } = useSWR(
|
||||
'/api/admin/settings',
|
||||
authenticatedFetcher,
|
||||
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
|
||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||
)}
|
||||
|
||||
{/* Reported Issues */}
|
||||
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
|
||||
<ReportedIssuesSection issues={reportedIssuesData.issues} />
|
||||
)}
|
||||
|
||||
{/* Active Downloads */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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');
|
||||
|
||||
@@ -43,12 +44,7 @@ interface ModalState {
|
||||
backend?: NotificationBackend;
|
||||
}
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
request_pending_approval: 'Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
const eventLabels: Record<string, string> = EVENT_LABELS;
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user