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:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
+1
View File
@@ -26,6 +26,7 @@ export interface Audiobook {
requestStatus?: string | null; // Status of request (if any)
requestId?: string | null; // ID of request (if any)
requestedByUsername?: string | null; // Username who requested (only if not current user)
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
+127
View File
@@ -0,0 +1,127 @@
/**
* Component: Goodreads Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export interface GoodreadsShelf {
id: string;
name: string;
rssUrl: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Component: Reported Issues Hooks
* Documentation: documentation/backend/services/reported-issues.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
/**
* Hook for reporting an issue with an audiobook (user action)
*/
export function useReportIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reportIssue = async (
asin: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/report-issue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, ...metadata }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to report issue');
}
// Revalidate audiobook lists to show issue indicator
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { reportIssue, isLoading, error };
}
/**
* Hook for fetching open reported issues (admin dashboard)
*/
export function useAdminReportedIssues() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/admin/reported-issues' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 10000,
});
return {
issues: data?.issues || [],
count: data?.count || 0,
isLoading,
error,
};
}
/**
* Hook for dismissing a reported issue (admin action)
*/
export function useDismissIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dismissIssue = async (issueId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss' }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to dismiss issue');
}
// Revalidate issues list
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { dismissIssue, isLoading, error };
}
/**
* Hook for replacing audiobook content via reported issue (admin action)
*/
export function useReplaceWithTorrent() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const replaceWithTorrent = async (issueId: string, torrent: any) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to replace audiobook');
}
// Revalidate issues list and audiobook lists
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { replaceWithTorrent, isLoading, error };
}