From cfe780c6f0e70e656e47a07bad43a62b8953bd64 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Fri, 27 Feb 2026 15:10:27 -0700 Subject: [PATCH 01/19] Hardcover API support --- .vscode/settings.json | 23 + FEATURE.md | 1 + package-lock.json | 12 +- prisma/schema.prisma | 43 ++ .../api/user/hardcover-shelves/[id]/route.ts | 55 ++ src/app/api/user/hardcover-shelves/route.ts | 206 ++++++++ src/app/profile/page.tsx | 97 +++- .../profile/HardcoverShelvesSection.tsx | 434 ++++++++++++++++ src/components/ui/AddHardcoverShelfModal.tsx | 206 ++++++++ src/lib/hooks/useHardcoverShelves.ts | 135 +++++ .../sync-hardcover-shelves.processor.ts | 46 ++ src/lib/services/hardcover-sync.service.ts | 479 ++++++++++++++++++ src/lib/services/job-queue.service.ts | 429 +++++++++++----- src/lib/services/scheduler.service.ts | 82 ++- 14 files changed, 2086 insertions(+), 162 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 FEATURE.md create mode 100644 src/app/api/user/hardcover-shelves/[id]/route.ts create mode 100644 src/app/api/user/hardcover-shelves/route.ts create mode 100644 src/components/profile/HardcoverShelvesSection.tsx create mode 100644 src/components/ui/AddHardcoverShelfModal.tsx create mode 100644 src/lib/hooks/useHardcoverShelves.ts create mode 100644 src/lib/processors/sync-hardcover-shelves.processor.ts create mode 100644 src/lib/services/hardcover-sync.service.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e5cc735 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#21b789", + "activityBar.background": "#21b789", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#f3e4fa", + "activityBarBadge.foreground": "#15202b", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#21b789", + "statusBar.background": "#198c69", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#21b789", + "statusBarItem.remoteBackground": "#198c69", + "statusBarItem.remoteForeground": "#e7e7e7", + "tab.activeBorder": "#21b789", + "titleBar.activeBackground": "#198c69", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#198c6999", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#198c69" +} diff --git a/FEATURE.md b/FEATURE.md new file mode 100644 index 0000000..f38ea3c --- /dev/null +++ b/FEATURE.md @@ -0,0 +1 @@ +Add gemini API integration for BookDate diff --git a/package-lock.json b/package-lock.json index f0a3dbe..cc16af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "dependencies": { "@heroicons/react": "^2.2.0", "@prisma/client": "^6.19.0", @@ -299,7 +299,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -309,7 +309,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -343,7 +343,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -403,7 +403,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0d5a26..40ac5b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,7 @@ model User { bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] goodreadsShelves GoodreadsShelf[] + hardcoverShelves HardcoverShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") @@ -530,3 +531,45 @@ model GoodreadsBookMapping { @@index([audibleAsin]) @@map("goodreads_book_mappings") } + +// ============================================================================ +// HARDCOVER SYNC TABLES +// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache +// ============================================================================ + +model HardcoverShelf { + id String @id @default(uuid()) + userId String @map("user_id") + name String // Extracted from Hardcover API list name or status + listId String @map("list_id") // Hardcover List ID or Status ID + apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api + lastSyncAt DateTime? @map("last_sync_at") + bookCount Int? @map("book_count") + coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, listId]) + @@index([userId]) + @@map("hardcover_shelves") +} + +model HardcoverBookMapping { + id String @id @default(uuid()) + hardcoverBookId String @unique @map("hardcover_book_id") // Internal ID from Hardcover + title String + author String + audibleAsin String? @map("audible_asin") + coverUrl String? @map("cover_url") @db.Text + noMatch Boolean @default(false) @map("no_match") + lastSearchAt DateTime? @map("last_search_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([hardcoverBookId]) + @@index([audibleAsin]) + @@map("hardcover_book_mappings") +} diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts new file mode 100644 index 0000000..479b146 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -0,0 +1,55 @@ +/** + * Component: Hardcover Shelf Delete Route + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.HardcoverShelves'); + +/** + * DELETE /api/user/hardcover-shelves/[id] + * Remove a Hardcover shelf subscription (ownership check) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const shelf = await prisma.hardcoverShelf.findUnique({ + where: { id }, + }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + // Ownership check + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await prisma.hardcoverShelf.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to delete list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to delete list' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts new file mode 100644 index 0000000..c1489b3 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -0,0 +1,206 @@ +/** + * Component: Hardcover Shelves API Routes + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.HardcoverShelves'); + +const AddShelfSchema = z.object({ + listId: z.string().min(1, { message: 'List ID is required' }), + apiToken: z.string().min(1, { message: 'API Token is required' }), +}); + +/** + * GET /api/user/hardcover-shelves + * List the current user's Hardcover lists with book counts and covers + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shelves = await prisma.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + const shelvesWithMeta = shelves.map((shelf) => { + let books: { + coverUrl: string; + asin: string | null; + title: string; + author: string; + }[] = []; + if (shelf.coverUrls) { + const parsed = JSON.parse(shelf.coverUrls); + if (Array.isArray(parsed)) { + books = parsed.map((item: unknown) => { + if (typeof item === 'string') { + return { coverUrl: item, asin: null, title: '', author: '' }; + } + const obj = item as Record; + return { + coverUrl: (obj.coverUrl as string) || '', + asin: (obj.asin as string) || null, + title: (obj.title as string) || '', + author: (obj.author as string) || '', + }; + }); + } + } + + return { + id: shelf.id, + name: shelf.name, + listId: shelf.listId, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount ?? null, + books, + }; + }); + + return NextResponse.json({ success: true, shelves: shelvesWithMeta }); + } catch (error) { + logger.error('Failed to list Hardcover lists', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list Hardcover lists' }, + { status: 500 }, + ); + } + }); +} + +/** + * POST /api/user/hardcover-shelves + * Add a new Hardcover list subscription + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { listId, apiToken } = AddShelfSchema.parse(body); + + // Check for duplicate + const existing = await prisma.hardcoverShelf.findUnique({ + where: { userId_listId: { userId: req.user.id, listId } }, + }); + + if (existing) { + return NextResponse.json( + { + error: 'DuplicateShelf', + message: 'You have already added this list', + }, + { status: 409 }, + ); + } + + // Validate by fetching the Hardcover GraphQL feed + let listName: string; + let bookCount: number; + let initialBooks: { + coverUrl: string; + asin: null; + title: string; + author: string; + }[] = []; + try { + const fetchedData = await fetchHardcoverList(apiToken, listId); + listName = fetchedData.listName; + bookCount = fetchedData.books.length; + initialBooks = fetchedData.books + .filter((b) => b.coverUrl) + .slice(0, 8) + .map((b) => ({ + coverUrl: b.coverUrl!, + asin: null, + title: b.title, + author: b.author, + })); + } catch (error) { + return NextResponse.json( + { + error: 'InvalidHardcoverList', + message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const shelf = await prisma.hardcoverShelf.create({ + data: { + userId: req.user.id, + name: listName, + listId, + apiToken, + bookCount, + coverUrls: + initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, + }, + }); + + // Trigger immediate sync for this shelf (unlimited lookups, process all books) + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncHardcoverShelvesJob(undefined, shelf.id, 0); + logger.info( + `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, + ); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json( + { + success: true, + shelf: { + id: shelf.id, + name: shelf.name, + listId: shelf.listId, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount, + books: initialBooks, + }, + bookCount, + }, + { status: 201 }, + ); + } catch (error) { + logger.error('Failed to add Hardcover list', { + 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: 'Failed to add Hardcover list' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 1d88640..19ad798 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -19,7 +20,11 @@ const statConfig = [ { key: 'waiting', label: 'Waiting', color: 'text-amber-500' }, { key: 'completed', label: 'Complete', color: 'text-emerald-500' }, { key: 'failed', label: 'Failed', color: 'text-red-500' }, - { key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' }, + { + key: 'cancelled', + label: 'Cancelled', + color: 'text-gray-400 dark:text-gray-500', + }, ] as const; type StatKey = (typeof statConfig)[number]['key']; @@ -30,25 +35,45 @@ export default function ProfilePage() { const stats = useMemo(() => { if (!requests.length) { - return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 }; + return { + total: 0, + completed: 0, + active: 0, + waiting: 0, + failed: 0, + cancelled: 0, + }; } return { total: requests.length, - completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length, - active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length, - waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length, + completed: requests.filter((r: any) => + ['available', 'downloaded'].includes(r.status), + ).length, + active: requests.filter((r: any) => + ['pending', 'searching', 'downloading', 'processing'].includes( + r.status, + ), + ).length, + waiting: requests.filter((r: any) => + ['awaiting_search', 'awaiting_import'].includes(r.status), + ).length, failed: requests.filter((r: any) => r.status === 'failed').length, cancelled: requests.filter((r: any) => r.status === 'cancelled').length, }; }, [requests]); const activeDownloads = useMemo(() => { - return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status)); + return requests.filter((r: any) => + ['downloading', 'processing'].includes(r.status), + ); }, [requests]); const recentRequests = useMemo(() => { return [...requests] - .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .sort( + (a: any, b: any) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) .slice(0, 5); }, [requests]); @@ -58,8 +83,18 @@ export default function ProfilePage() {
- - + +

@@ -113,7 +148,7 @@ export default function ProfilePage() { 'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide', user.role === 'admin' ? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400' - : 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400' + : 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400', )} > {user.role === 'admin' ? 'Administrator' : 'User'} @@ -128,7 +163,12 @@ export default function ProfilePage() { key={stat.key} className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800" > -
+
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
@@ -142,6 +182,9 @@ export default function ProfilePage() { {/* Goodreads Shelves */} + {/* Hardcover Lists */} + + {/* Active Downloads */} {activeDownloads.length > 0 && (
@@ -158,7 +201,11 @@ export default function ProfilePage() {
{activeDownloads.map((request: any) => ( - + ))}
@@ -201,7 +248,11 @@ export default function ProfilePage() { ) : recentRequests.length > 0 ? (
{recentRequests.map((request: any) => ( - + ))}
) : ( @@ -213,7 +264,11 @@ export default function ProfilePage() { viewBox="0 0 24 24" strokeWidth={1.5} > - +

No requests yet @@ -225,8 +280,18 @@ export default function ProfilePage() { href="/search" className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors" > - - + + Search Audiobooks diff --git a/src/components/profile/HardcoverShelvesSection.tsx b/src/components/profile/HardcoverShelvesSection.tsx new file mode 100644 index 0000000..67abc15 --- /dev/null +++ b/src/components/profile/HardcoverShelvesSection.tsx @@ -0,0 +1,434 @@ +/** + * Component: Hardcover Shelves Section (Profile Page) + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { + useHardcoverShelves, + useDeleteHardcoverShelf, + HardcoverShelf, + ShelfBook, +} from '@/lib/hooks/useHardcoverShelves'; +import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal'; +import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; +import { usePreferences } from '@/contexts/PreferencesContext'; +import { cn } from '@/lib/utils/cn'; + +function formatRelativeTime(dateStr: string | null): string { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +export function HardcoverShelvesSection() { + const { shelves, isLoading } = useHardcoverShelves(); + const { deleteShelf, isLoading: isDeleting } = useDeleteHardcoverShelf(); + const { squareCovers } = usePreferences(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [selectedAsin, setSelectedAsin] = useState(null); + + const handleDelete = async (shelfId: string) => { + try { + await deleteShelf(shelfId); + setConfirmDeleteId(null); + } catch { + // Error handled by hook + } + }; + + return ( +
+ {/* Section Header */} +

+
+
+ + + +
+
+

+ Hardcover Lists +

+ {!isLoading && shelves.length > 0 && ( +

+ {shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '} + connected +

+ )} +
+
+ + +
+ + {/* Content */} + {isLoading ? ( + + ) : shelves.length > 0 ? ( +
+ {shelves.map((shelf) => ( + handleDelete(shelf.id)} + onConfirmDelete={() => setConfirmDeleteId(shelf.id)} + onCancelDelete={() => setConfirmDeleteId(null)} + onBookClick={(asin) => setSelectedAsin(asin)} + /> + ))} +
+ ) : ( + setShowAddModal(true)} /> + )} + + setShowAddModal(false)} + /> + + {/* Audiobook Detail Modal (read-only) */} + {selectedAsin && ( + setSelectedAsin(null)} + hideRequestActions + /> + )} + + ); +} + +/* ─── Empty State ─── */ + +function EmptyState({ onAdd }: { onAdd: () => void }) { + return ( +
+
+ + + +
+ +

+ Connect your reading list +

+

+ Link a Hardcover list and we'll automatically request the audiobook for + every book you add. +

+ + +
+ ); +} + +/* ─── Loading Skeleton ─── */ + +function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { + return ( +
+
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
0 ? '-16px' : 0, zIndex: 5 - i }} + /> + ))} +
+
+ ); +} + +/* ─── Shelf Card ─── */ + +interface ShelfCardProps { + shelf: HardcoverShelf; + squareCovers: boolean; + isDeleting: boolean; + isConfirmingDelete: boolean; + onDelete: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onBookClick: (asin: string) => void; +} + +function ShelfCard({ + shelf, + squareCovers, + isDeleting, + isConfirmingDelete, + onDelete, + onConfirmDelete, + onCancelDelete, + onBookClick, +}: ShelfCardProps) { + const displayBooks = shelf.books.slice(0, 6); + const hasCovers = displayBooks.length > 0; + const remainingCount = Math.max( + 0, + (shelf.bookCount || 0) - displayBooks.length, + ); + const isSyncing = !shelf.lastSyncAt; + + return ( +
+ {/* Top: Shelf info + actions */} +
+
+

+ {shelf.name} +

+
+ {shelf.bookCount != null && ( + + {shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'} + + )} + + {isSyncing ? ( + <> + + + + + Syncing… + + ) : shelf.lastSyncAt ? ( + <> + + Synced {formatRelativeTime(shelf.lastSyncAt)} + + ) : ( + 'Pending sync' + )} + +
+
+ + {/* Delete action */} +
+ {isConfirmingDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* Bottom: Stacked book covers */} + {hasCovers ? ( + + ) : isSyncing ? ( +
+ {[...Array(3)].map((_, i) => ( +
0 ? '-16px' : 0, zIndex: 3 - i }} + /> + ))} +
+ ) : null} +
+ ); +} + +/* ─── Stacked Cover Display ─── */ + +function CoverStack({ + books, + remainingCount, + squareCovers, + onBookClick, +}: { + books: ShelfBook[]; + remainingCount: number; + squareCovers: boolean; + onBookClick: (asin: string) => void; +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const coverSize = squareCovers + ? 'w-[80px] aspect-square' + : 'w-[72px] aspect-[2/3]'; + + return ( +
+ {books.map((book, i) => ( +
0 ? '-16px' : 0, + zIndex: hoveredIndex === i ? 50 : books.length - i, + }} + onMouseEnter={() => setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => book.asin && onBookClick(book.asin)} + title={ + book.asin + ? `${book.title}${book.author ? ` by ${book.author}` : ''}` + : undefined + } + > + +
+ ))} + {remainingCount > 0 && ( +
+ + +{remainingCount} + +
+ )} +
+ ); +} diff --git a/src/components/ui/AddHardcoverShelfModal.tsx b/src/components/ui/AddHardcoverShelfModal.tsx new file mode 100644 index 0000000..6ac45f7 --- /dev/null +++ b/src/components/ui/AddHardcoverShelfModal.tsx @@ -0,0 +1,206 @@ +/** + * Component: Add Hardcover Shelf Modal + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { Input } from './Input'; +import { Button } from './Button'; +import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; + +interface AddHardcoverShelfModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function AddHardcoverShelfModal({ + isOpen, + onClose, +}: AddHardcoverShelfModalProps) { + const [apiToken, setApiToken] = useState(''); + const [listId, setListId] = useState(''); + const [validationError, setValidationError] = useState(''); + const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const { addShelf, isLoading, error } = useAddHardcoverShelf(); + + const validateInput = (): boolean => { + if (!apiToken.trim()) { + setValidationError('Hardcover API Token is required'); + return false; + } + if (!listId.trim()) { + setValidationError('Hardcover List ID or Status ID is required'); + return false; + } + setValidationError(''); + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateInput()) return; + + try { + const shelf = await addShelf(apiToken.trim(), listId.trim()); + setSuccess(true); + setSuccessMessage(`Added list "${shelf.name}" successfully!`); + setApiToken(''); + setListId(''); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } catch { + // Error is handled by the hook + } + }; + + const handleClose = () => { + setApiToken(''); + setListId(''); + setValidationError(''); + setSuccess(false); + setSuccessMessage(''); + onClose(); + }; + + return ( + +
+ {/* Visual header */} +
+
+ + + +
+
+

+ Provides your Hardcover API token and the ID of the list you want + to sync. +

+
+
+ + {/* Success alert */} + {success && ( +
+
+ + + +
+

+ {successMessage} +

+
+ )} + + {/* Error alert */} + {error && ( +
+
+ + + +
+

+ {error} +

+
+ )} + + {/* Form */} +
+
+ { + setApiToken(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="eyJhb..." + disabled={isLoading || success} + /> + { + setListId(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="1234 or uuid" + error={validationError} + disabled={isLoading || success} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts new file mode 100644 index 0000000..6dd24a6 --- /dev/null +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -0,0 +1,135 @@ +/** + * Component: Hardcover 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 HardcoverShelf { + id: string; + name: string; + listId: string; + lastSyncAt: string | null; + createdAt: string; + bookCount: number | null; + books: ShelfBook[]; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +export function useHardcoverShelves() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/hardcover-shelves' : null; + + const { data, error, isLoading } = useSWR(endpoint, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as HardcoverShelf[], + isLoading, + error, + }; +} + +export function useAddHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addShelf = async (apiToken: string, listId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth('/api/user/hardcover-shelves', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiToken, listId }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to add list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-shelves'), + ); + + return data.shelf as HardcoverShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addShelf, isLoading, error }; +} + +export function useDeleteHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteShelf = async (shelfId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/hardcover-shelves/${shelfId}`, + { + method: 'DELETE', + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to remove list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-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 }; +} diff --git a/src/lib/processors/sync-hardcover-shelves.processor.ts b/src/lib/processors/sync-hardcover-shelves.processor.ts new file mode 100644 index 0000000..e9746c1 --- /dev/null +++ b/src/lib/processors/sync-hardcover-shelves.processor.ts @@ -0,0 +1,46 @@ +/** + * Component: Sync Hardcover Shelves Processor + * Documentation: documentation/backend/services/scheduler.md + * + * Dedicated processor for syncing Hardcover lists. + * Resolves books to Audible ASINs and creates requests. + */ + +import { RMABLogger } from '../utils/logger'; + +export interface SyncHardcoverShelvesPayload { + jobId?: string; + scheduledJobId?: string; + /** If set, only process this specific list (used for immediate sync on add) */ + shelfId?: string; + /** Max Audible lookups per list. 0 = unlimited. */ + maxLookupsPerShelf?: number; +} + +export async function processSyncHardcoverShelves( + payload: SyncHardcoverShelvesPayload, +): Promise { + const { jobId, shelfId, maxLookupsPerShelf } = payload; + const logger = RMABLogger.forJob(jobId, 'SyncHardcoverShelves'); + + logger.info( + shelfId + ? `Starting immediate Hardcover sync for list ${shelfId}...` + : 'Starting scheduled Hardcover lists sync...', + ); + + const { processHardcoverShelves } = + await import('../services/hardcover-sync.service'); + const stats = await processHardcoverShelves(logger, { + shelfId, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + logger.info('Hardcover sync complete', { stats }); + + return { + success: true, + message: shelfId ? 'Hardcover list synced' : 'Hardcover lists synced', + ...stats, + }; +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts new file mode 100644 index 0000000..2149c40 --- /dev/null +++ b/src/lib/services/hardcover-sync.service.ts @@ -0,0 +1,479 @@ +/** + * Component: Hardcover Shelf Sync Service + * Documentation: documentation/backend/services/hardcover-sync.md + * + * Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs, + * and creates requests via the shared request-creator service. + */ + +import axios from 'axios'; +import { prisma } from '@/lib/db'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('HardcoverSync'); + +/** Default max Audible lookups per shelf per scheduled sync cycle */ +const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10; + +/** Days before retrying a noMatch book */ +const NO_MATCH_RETRY_DAYS = 7; + +const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; + +interface HardcoverApiBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** + * Fetch a Hardcover List using their GraphQL API. + * This handles both 'status_id' user_books or 'list_id' list_books queries. + * For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id. + */ +export async function fetchHardcoverList( + apiToken: string, + listIdStr: string, +): Promise<{ listName: string; books: HardcoverApiBook[] }> { + // If we can parse as integer, it could be a List ID or Status ID. If UUID, we adjust query + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + listIdStr, + ); + + // Example generic query to Hardcover. Adjust the table/format as needed for their schema. + // Hardcover lists use custom lists (list_books) or statuses (user_books). + // Assuming list_books for this implementation. + const query = ` + query GetListBooks($listId: Int!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { + name + } + book { + id + title + author_books { + author { + name + } + } + cached_image + image { + url + } + } + } + } + `; + + // Provide fallback UUID query if Hardcover uses UUIDs instead. + const queryUuid = ` + query GetListBooksUuid($listId: uuid!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { + name + } + book { + id + title + author_books { + author { + name + } + } + cached_image + image { + url + } + } + } + } + `; + + const response = await axios.post( + HARDCOVER_API_URL, + { + query: isUuid ? queryUuid : query, + variables: { + listId: isUuid ? listIdStr : parseInt(listIdStr, 10), + }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 15000, + }, + ); + + if (response.data?.errors) { + throw new Error(`Hardcover API Error: ${response.data.errors[0]?.message}`); + } + + const listBooks = response.data?.data?.list_books || []; + let listName = 'Hardcover List'; + if (listBooks.length > 0 && listBooks[0].list?.name) { + listName = listBooks[0].list.name; + } + + const books: HardcoverApiBook[] = []; + for (const item of listBooks) { + const book = item.book; + if (!book || !book.id) continue; + + // Hardcover authors can be multiple, we pick the first one or join them + const authorName = book.author_books?.[0]?.author?.name || 'Unknown Author'; + const coverUrl = book.cached_image || book.image?.url || undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; +} + +export interface HardcoverSyncStats { + shelvesProcessed: number; + booksFound: number; + lookupsPerformed: number; + requestsCreated: number; + errors: number; +} + +export interface HardcoverSyncOptions { + shelfId?: string; + maxLookupsPerShelf?: number; +} + +export async function processHardcoverShelves( + jobLogger?: ReturnType, + options: HardcoverSyncOptions = {}, +): Promise { + const log = jobLogger || logger; + const stats: HardcoverSyncStats = { + shelvesProcessed: 0, + booksFound: 0, + lookupsPerformed: 0, + requestsCreated: 0, + errors: 0, + }; + + const maxLookups = + options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; + + const whereClause = options.shelfId ? { id: options.shelfId } : {}; + const shelves = await prisma.hardcoverShelf.findMany({ + where: whereClause, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + if (shelves.length === 0) { + log.info( + options.shelfId + ? 'Hardcover list not found' + : 'No Hardcover lists configured, skipping', + ); + return stats; + } + + log.info( + `Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`, + ); + + for (const shelf of shelves) { + try { + await processShelf(shelf, stats, log, maxLookups); + stats.shelvesProcessed++; + } catch (error) { + stats.errors++; + log.error( + `Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + log.info( + `Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`, + ); + return stats; +} + +async function processShelf( + shelf: { + id: string; + listId: string; + apiToken: string; + name: string; + user: { id: string; plexUsername: string }; + }, + stats: HardcoverSyncStats, + log: + | ReturnType + | ReturnType, + maxLookups: number, +) { + log.info( + `Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`, + ); + + let fetchedData: { listName: string; books: HardcoverApiBook[] }; + try { + fetchedData = await fetchHardcoverList(shelf.apiToken, shelf.listId); + } catch (error) { + log.error( + `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + return; + } + + const books = fetchedData.books; + stats.booksFound += books.length; + log.info( + `Found ${books.length} books in list "${shelf.name}" (Hardcover API)`, + ); + + let lookupsThisCycle = 0; + const unlimitedLookups = maxLookups === 0; + + for (const book of books) { + let mapping = await prisma.hardcoverBookMapping.findUnique({ + where: { hardcoverBookId: book.bookId }, + }); + + if (!mapping) { + if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue; + + mapping = await performAudibleLookup(book, log); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } + + if (mapping.noMatch) { + if (mapping.lastSearchAt) { + const daysSinceSearch = + (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24); + if ( + daysSinceSearch >= NO_MATCH_RETRY_DAYS && + (unlimitedLookups || lookupsThisCycle < maxLookups) + ) { + log.info( + `Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`, + ); + mapping = await performAudibleLookup(book, log, mapping.id); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } else { + continue; + } + } else { + continue; + } + } + + if (mapping.audibleAsin) { + try { + const result = await createRequestForUser(shelf.user.id, { + asin: mapping.audibleAsin, + title: mapping.title, + author: mapping.author, + coverArtUrl: mapping.coverUrl || undefined, + }); + + if (result.success) { + stats.requestsCreated++; + log.info( + `Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`, + ); + } + } catch (error) { + log.error( + `Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } + + // Collect enriched book data for display + const bookIds = books.map((b) => b.bookId); + const mappings = + bookIds.length > 0 + ? await prisma.hardcoverBookMapping.findMany({ + where: { hardcoverBookId: { in: bookIds } }, + select: { + hardcoverBookId: true, + audibleAsin: true, + title: true, + author: true, + coverUrl: true, + }, + }) + : []; + const mappingsByBookId = new Map(mappings.map((m) => [m.hardcoverBookId, m])); + + const matchedAsins = mappings + .map((m) => m.audibleAsin) + .filter((asin): asin is string => !!asin); + const cachedCovers = + matchedAsins.length > 0 + ? await prisma.audibleCache.findMany({ + where: { asin: { in: matchedAsins } }, + select: { asin: true, coverArtUrl: true, cachedCoverPath: true }, + }) + : []; + const coverByAsin = new Map( + cachedCovers + .filter((c) => c.cachedCoverPath || c.coverArtUrl) + .map((c) => { + let coverUrl = c.coverArtUrl || ''; + if (c.cachedCoverPath) { + const filename = c.cachedCoverPath.split('/').pop(); + coverUrl = `/api/cache/thumbnails/${filename}`; + } + return [c.asin, coverUrl] as const; + }), + ); + + const bookData = books + .map((b) => { + const mapping = mappingsByBookId.get(b.bookId); + const coverUrl = + coverByAsin.get(mapping?.audibleAsin || '') || + mapping?.coverUrl || + b.coverUrl; + if (!coverUrl) return null; + return { + coverUrl, + asin: mapping?.audibleAsin || null, + title: mapping?.title || b.title, + author: mapping?.author || b.author, + }; + }) + .filter((b): b is NonNullable => b !== null) + .slice(0, 8); + + const finalListName = + fetchedData.listName !== 'Hardcover List' + ? fetchedData.listName + : shelf.name; + + await prisma.hardcoverShelf.update({ + where: { id: shelf.id }, + data: { + name: finalListName, + lastSyncAt: new Date(), + bookCount: books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); +} + +async function performAudibleLookup( + book: HardcoverApiBook, + log: + | ReturnType + | ReturnType, + existingMappingId?: string, +): Promise { + const audibleService = getAudibleService(); + + try { + const fullQuery = `${book.title} ${book.author}`; + log.info(`Searching Audible for: "${fullQuery}"`); + + let searchResult = await audibleService.search(fullQuery); + let firstResult = searchResult.results[0]; + + if (!firstResult?.asin) { + const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim(); + if (cleanTitle !== book.title) { + const cleanQuery = `${cleanTitle} ${book.author}`; + log.info( + `No results with full title, retrying without series info: "${cleanQuery}"`, + ); + searchResult = await audibleService.search(cleanQuery); + firstResult = searchResult.results[0]; + } + } + + if (firstResult?.asin) { + log.info( + `Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`, + ); + + const data = { + title: firstResult.title, + author: firstResult.author, + audibleAsin: firstResult.asin, + coverUrl: firstResult.coverArtUrl || book.coverUrl || null, + noMatch: false, + lastSearchAt: new Date(), + }; + + if (existingMappingId) { + return prisma.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...data }, + }); + } + + log.info(`No Audible match for "${book.title}" by ${book.author}`); + + const noMatchData = { + title: book.title, + author: book.author, + coverUrl: book.coverUrl || null, + noMatch: true, + lastSearchAt: new Date(), + audibleAsin: null, + }; + + if (existingMappingId) { + return prisma.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data: noMatchData, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...noMatchData }, + }); + } catch (error) { + log.error( + `Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + + const errorData = { + title: book.title, + author: book.author, + coverUrl: book.coverUrl || null, + noMatch: true, + lastSearchAt: new Date(), + }; + + if (existingMappingId) { + return prisma.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data: errorData, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...errorData }, + }); + } +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 76e8680..79ee5ab 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -27,6 +27,7 @@ export type JobType = | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' + | 'sync_hardcover_shelves' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -63,8 +64,8 @@ export interface MonitorDownloadPayload extends JobPayload { downloadHistoryId: string; downloadClientId: string; downloadClient: DownloadClientType; - lastProgress?: number; // Previous poll's progress (0-100) for stall detection - stallCount?: number; // Consecutive polls with no progress change (drives backoff) + lastProgress?: number; // Previous poll's progress (0-100) for stall detection + stallCount?: number; // Consecutive polls with no progress change (drives backoff) pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path } @@ -111,6 +112,12 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload { maxLookupsPerShelf?: number; } +export interface SyncHardcoverShelvesPayload extends JobPayload { + scheduledJobId?: string; + shelfId?: string; + maxLookupsPerShelf?: number; +} + // Ebook-specific payload interfaces export interface SearchEbookPayload extends JobPayload { requestId: string; @@ -226,13 +233,15 @@ export class JobQueueService { 'failed', null, error.message, - error.stack + error.stack, ); // Handle permanent failures for specific job types after all retries exhausted if (job.name === 'monitor_download' && job.data) { const payload = job.data as MonitorDownloadPayload; - logger.error(`MonitorDownload job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`); + logger.error( + `MonitorDownload job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`, + ); // Update request status to failed (only happens after all retries exhausted) try { @@ -240,7 +249,9 @@ export class JobQueueService { where: { id: payload.requestId }, data: { status: 'failed', - errorMessage: error.message || 'Failed to monitor download after multiple retries', + errorMessage: + error.message || + 'Failed to monitor download after multiple retries', updatedAt: new Date(), }, }); @@ -256,7 +267,12 @@ export class JobQueueService { }); } } catch (updateError) { - logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) }); + logger.error('Failed to update request/download status', { + error: + updateError instanceof Error + ? updateError.message + : String(updateError), + }); } } }); @@ -280,106 +296,225 @@ export class JobQueueService { */ private startProcessors(): void { // Search indexers processor - this.queue.process('search_indexers', 2, async (job: BullJob) => { - const { processSearchIndexers } = await import('../processors/search-indexers.processor'); - return await processSearchIndexers(job.data); - }); + this.queue.process( + 'search_indexers', + 2, + async (job: BullJob) => { + const { processSearchIndexers } = + await import('../processors/search-indexers.processor'); + return await processSearchIndexers(job.data); + }, + ); // Download torrent processor - this.queue.process('download_torrent', 2, async (job: BullJob) => { - const { processDownloadTorrent } = await import('../processors/download-torrent.processor'); - return await processDownloadTorrent(job.data); - }); + this.queue.process( + 'download_torrent', + 2, + async (job: BullJob) => { + const { processDownloadTorrent } = + await import('../processors/download-torrent.processor'); + return await processDownloadTorrent(job.data); + }, + ); // Monitor download processor - this.queue.process('monitor_download', 2, async (job: BullJob) => { - const { processMonitorDownload } = await import('../processors/monitor-download.processor'); - return await processMonitorDownload(job.data); - }); + this.queue.process( + 'monitor_download', + 2, + async (job: BullJob) => { + const { processMonitorDownload } = + await import('../processors/monitor-download.processor'); + return await processMonitorDownload(job.data); + }, + ); // Organize files processor - this.queue.process('organize_files', 2, async (job: BullJob) => { - const { processOrganizeFiles } = await import('../processors/organize-files.processor'); - return await processOrganizeFiles(job.data); - }); + this.queue.process( + 'organize_files', + 2, + async (job: BullJob) => { + const { processOrganizeFiles } = + await import('../processors/organize-files.processor'); + return await processOrganizeFiles(job.data); + }, + ); // Scan Plex processor - this.queue.process('scan_plex', 1, async (job: BullJob) => { - const { processScanPlex } = await import('../processors/scan-plex.processor'); - return await processScanPlex(job.data); - }); + this.queue.process( + 'scan_plex', + 1, + async (job: BullJob) => { + const { processScanPlex } = + await import('../processors/scan-plex.processor'); + return await processScanPlex(job.data); + }, + ); // Scheduled job processors this.queue.process('plex_library_scan', 1, async (job: BullJob) => { // plex_library_scan is just an alias for scan_plex - const { processScanPlex } = await import('../processors/scan-plex.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'plex_library_scan'); + const { processScanPlex } = + await import('../processors/scan-plex.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'plex_library_scan', + ); return await processScanPlex(payloadWithJobId); }); - this.queue.process('plex_recently_added_check', 1, async (job: BullJob) => { - const { processPlexRecentlyAddedCheck } = await import('../processors/plex-recently-added.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'plex_recently_added_check'); - return await processPlexRecentlyAddedCheck(payloadWithJobId); - }); + this.queue.process( + 'plex_recently_added_check', + 1, + async (job: BullJob) => { + const { processPlexRecentlyAddedCheck } = + await import('../processors/plex-recently-added.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'plex_recently_added_check', + ); + return await processPlexRecentlyAddedCheck(payloadWithJobId); + }, + ); - this.queue.process('monitor_rss_feeds', 1, async (job: BullJob) => { - const { processMonitorRssFeeds } = await import('../processors/monitor-rss-feeds.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'monitor_rss_feeds'); - return await processMonitorRssFeeds(payloadWithJobId); - }); + this.queue.process( + 'monitor_rss_feeds', + 1, + async (job: BullJob) => { + const { processMonitorRssFeeds } = + await import('../processors/monitor-rss-feeds.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'monitor_rss_feeds', + ); + return await processMonitorRssFeeds(payloadWithJobId); + }, + ); - this.queue.process('audible_refresh', 1, async (job: BullJob) => { - const { processAudibleRefresh } = await import('../processors/audible-refresh.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'audible_refresh'); - return await processAudibleRefresh(payloadWithJobId); - }); + this.queue.process( + 'audible_refresh', + 1, + async (job: BullJob) => { + const { processAudibleRefresh } = + await import('../processors/audible-refresh.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'audible_refresh', + ); + return await processAudibleRefresh(payloadWithJobId); + }, + ); - this.queue.process('retry_missing_torrents', 1, async (job: BullJob) => { - const { processRetryMissingTorrents } = await import('../processors/retry-missing-torrents.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'retry_missing_torrents'); - return await processRetryMissingTorrents(payloadWithJobId); - }); + this.queue.process( + 'retry_missing_torrents', + 1, + async (job: BullJob) => { + const { processRetryMissingTorrents } = + await import('../processors/retry-missing-torrents.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'retry_missing_torrents', + ); + return await processRetryMissingTorrents(payloadWithJobId); + }, + ); - this.queue.process('retry_failed_imports', 1, async (job: BullJob) => { - const { processRetryFailedImports } = await import('../processors/retry-failed-imports.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'retry_failed_imports'); - return await processRetryFailedImports(payloadWithJobId); - }); + this.queue.process( + 'retry_failed_imports', + 1, + async (job: BullJob) => { + const { processRetryFailedImports } = + await import('../processors/retry-failed-imports.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'retry_failed_imports', + ); + return await processRetryFailedImports(payloadWithJobId); + }, + ); - this.queue.process('cleanup_seeded_torrents', 1, async (job: BullJob) => { - const { processCleanupSeededTorrents } = await import('../processors/cleanup-seeded-torrents.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents'); - return await processCleanupSeededTorrents(payloadWithJobId); - }); + this.queue.process( + 'cleanup_seeded_torrents', + 1, + async (job: BullJob) => { + const { processCleanupSeededTorrents } = + await import('../processors/cleanup-seeded-torrents.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'cleanup_seeded_torrents', + ); + return await processCleanupSeededTorrents(payloadWithJobId); + }, + ); - this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob) => { - const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves'); - return await processSyncGoodreadsShelves(payloadWithJobId); - }); + this.queue.process( + 'sync_goodreads_shelves', + 1, + async (job: BullJob) => { + const { processSyncGoodreadsShelves } = + await import('../processors/sync-goodreads-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'sync_goodreads_shelves', + ); + return await processSyncGoodreadsShelves(payloadWithJobId); + }, + ); + + this.queue.process( + 'sync_hardcover_shelves', + 1, + async (job: BullJob) => { + const { processSyncHardcoverShelves } = + await import('../processors/sync-hardcover-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'sync_hardcover_shelves', + ); + return await processSyncHardcoverShelves(payloadWithJobId); + }, + ); // Send notification processor - this.queue.process('send_notification', 2, async (job: BullJob) => { - const { processSendNotification } = await import('../processors/send-notification.processor'); - return await processSendNotification(job.data); - }); + this.queue.process( + 'send_notification', + 2, + async (job: BullJob) => { + const { processSendNotification } = + await import('../processors/send-notification.processor'); + return await processSendNotification(job.data); + }, + ); // Ebook-specific processors - this.queue.process('search_ebook', 2, async (job: BullJob) => { - const { processSearchEbook } = await import('../processors/search-ebook.processor'); - return await processSearchEbook(job.data); - }); + this.queue.process( + 'search_ebook', + 2, + async (job: BullJob) => { + const { processSearchEbook } = + await import('../processors/search-ebook.processor'); + return await processSearchEbook(job.data); + }, + ); - this.queue.process('start_direct_download', 2, async (job: BullJob) => { - const { processStartDirectDownload } = await import('../processors/direct-download.processor'); - return await processStartDirectDownload(job.data); - }); + this.queue.process( + 'start_direct_download', + 2, + async (job: BullJob) => { + const { processStartDirectDownload } = + await import('../processors/direct-download.processor'); + return await processStartDirectDownload(job.data); + }, + ); - this.queue.process('monitor_direct_download', 2, async (job: BullJob) => { - const { processMonitorDirectDownload } = await import('../processors/direct-download.processor'); - return await processMonitorDirectDownload(job.data); - }); + this.queue.process( + 'monitor_direct_download', + 2, + async (job: BullJob) => { + const { processMonitorDirectDownload } = + await import('../processors/direct-download.processor'); + return await processMonitorDirectDownload(job.data); + }, + ); } /** @@ -404,12 +539,17 @@ export class JobQueueService { if (existingJob) { // Update lastRun for the scheduled job if this is a timer-triggered job if (payload.scheduledJobId) { - await prisma.scheduledJob.update({ - where: { id: payload.scheduledJobId }, - data: { lastRun: new Date() }, - }).catch(err => { - logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) }); - }); + await prisma.scheduledJob + .update({ + where: { id: payload.scheduledJobId }, + data: { lastRun: new Date() }, + }) + .catch((err) => { + logger.error( + `Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, + { error: err instanceof Error ? err.message : String(err) }, + ); + }); } return { ...payload, jobId: existingJob.id }; } @@ -429,12 +569,17 @@ export class JobQueueService { // Update lastRun for the scheduled job if this is a timer-triggered job if (payload.scheduledJobId) { - await prisma.scheduledJob.update({ - where: { id: payload.scheduledJobId }, - data: { lastRun: new Date() }, - }).catch(err => { - logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) }); - }); + await prisma.scheduledJob + .update({ + where: { id: payload.scheduledJobId }, + data: { lastRun: new Date() }, + }) + .catch((err) => { + logger.error( + `Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, + { error: err instanceof Error ? err.message : String(err) }, + ); + }); } return { ...payload, jobId: dbJob.id }; @@ -448,7 +593,7 @@ export class JobQueueService { status: string, result?: any, errorMessage?: string, - stackTrace?: string + stackTrace?: string, ): Promise { try { const updateData: any = { @@ -481,7 +626,9 @@ export class JobQueueService { data: updateData, }); } catch (error) { - logger.error('Failed to update job in database', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to update job in database', { + error: error instanceof Error ? error.message : String(error), + }); } } @@ -491,7 +638,7 @@ export class JobQueueService { private async addJob( type: JobType, payload: JobPayload, - options?: JobOptions + options?: JobOptions, ): Promise { // First create the database job record const dbJob = await prisma.job.create({ @@ -524,7 +671,10 @@ export class JobQueueService { /** * Add search indexers job */ - async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise { + async addSearchJob( + requestId: string, + audiobook: { id: string; title: string; author: string; asin?: string }, + ): Promise { return await this.addJob( 'search_indexers', { @@ -533,7 +683,7 @@ export class JobQueueService { } as SearchIndexersPayload, { priority: 10, // High priority for user-initiated requests - } + }, ); } @@ -543,7 +693,7 @@ export class JobQueueService { async addDownloadJob( requestId: string, audiobook: { id: string; title: string; author: string }, - torrent: TorrentResult + torrent: TorrentResult, ): Promise { return await this.addJob( 'download_torrent', @@ -554,7 +704,7 @@ export class JobQueueService { } as DownloadTorrentPayload, { priority: 9, // High priority - download selected torrent - } + }, ); } @@ -569,7 +719,7 @@ export class JobQueueService { delaySeconds: number = 0, lastProgress?: number, stallCount?: number, - pathWaitCount?: number + pathWaitCount?: number, ): Promise { return await this.addJob( 'monitor_download', @@ -585,7 +735,7 @@ export class JobQueueService { { priority: 5, // Medium priority delay: delaySeconds * 1000, // Convert seconds to milliseconds - } + }, ); } @@ -597,7 +747,7 @@ export class JobQueueService { requestId: string, audiobookId: string, downloadPath: string, - targetPath?: string + targetPath?: string, ): Promise { return await this.addJob( 'organize_files', @@ -609,14 +759,18 @@ export class JobQueueService { } as OrganizeFilesPayload, { priority: 8, - } + }, ); } /** * Add Plex scan job */ - async addPlexScanJob(libraryId: string, partial?: boolean, path?: string): Promise { + async addPlexScanJob( + libraryId: string, + partial?: boolean, + path?: string, + ): Promise { return await this.addJob( 'scan_plex', { @@ -626,7 +780,7 @@ export class JobQueueService { } as ScanPlexPayload, { priority: 7, - } + }, ); } @@ -641,7 +795,7 @@ export class JobQueueService { } as PlexRecentlyAddedPayload, { priority: 8, - } + }, ); } @@ -656,7 +810,7 @@ export class JobQueueService { } as MonitorRssFeedsPayload, { priority: 8, - } + }, ); } @@ -671,7 +825,7 @@ export class JobQueueService { } as AudibleRefreshPayload, { priority: 9, - } + }, ); } @@ -686,7 +840,7 @@ export class JobQueueService { } as RetryMissingTorrentsPayload, { priority: 7, - } + }, ); } @@ -701,7 +855,7 @@ export class JobQueueService { } as RetryFailedImportsPayload, { priority: 7, - } + }, ); } @@ -716,14 +870,18 @@ export class JobQueueService { } as CleanupSeededTorrentsPayload, { priority: 10, - } + }, ); } /** * Add sync Goodreads shelves job */ - async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise { + async addSyncGoodreadsShelvesJob( + scheduledJobId?: string, + shelfId?: string, + maxLookupsPerShelf?: number, + ): Promise { return await this.addJob( 'sync_goodreads_shelves', { @@ -733,7 +891,28 @@ export class JobQueueService { } as SyncGoodreadsShelvesPayload, { priority: 7, - } + }, + ); + } + + /** + * Add sync Hardcover shelves job + */ + async addSyncHardcoverShelvesJob( + scheduledJobId?: string, + shelfId?: string, + maxLookupsPerShelf?: number, + ): Promise { + return await this.addJob( + 'sync_hardcover_shelves', + { + scheduledJobId, + shelfId, + maxLookupsPerShelf, + } as SyncHardcoverShelvesPayload, + { + priority: 7, + }, ); } @@ -747,7 +926,7 @@ export class JobQueueService { async addSearchEbookJob( requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }, - preferredFormat?: string + preferredFormat?: string, ): Promise { return await this.addJob( 'search_ebook', @@ -758,7 +937,7 @@ export class JobQueueService { } as SearchEbookPayload, { priority: 10, // High priority for user-initiated requests - } + }, ); } @@ -770,7 +949,7 @@ export class JobQueueService { downloadHistoryId: string, downloadUrl: string, targetFilename: string, - expectedSize?: number + expectedSize?: number, ): Promise { return await this.addJob( 'start_direct_download', @@ -783,7 +962,7 @@ export class JobQueueService { } as StartDirectDownloadPayload, { priority: 9, // High priority - download selected ebook - } + }, ); } @@ -796,7 +975,7 @@ export class JobQueueService { downloadId: string, targetPath: string, expectedSize?: number, - delaySeconds: number = 0 + delaySeconds: number = 0, ): Promise { return await this.addJob( 'monitor_direct_download', @@ -810,7 +989,7 @@ export class JobQueueService { { priority: 5, // Medium priority delay: delaySeconds * 1000, - } + }, ); } @@ -959,9 +1138,13 @@ export class JobQueueService { author: string, userName: string, message?: string, - requestType?: string + requestType?: string, ): Promise { - logger.info(`Queueing notification: ${event}`, { requestId, title, userName }); + logger.info(`Queueing notification: ${event}`, { + requestId, + title, + userName, + }); return await this.addJob( 'send_notification', { @@ -981,7 +1164,7 @@ export class JobQueueService { } as SendNotificationPayload, { priority: 5, // Medium priority - } + }, ); } @@ -992,7 +1175,7 @@ export class JobQueueService { jobType: string, payload: JobPayload, cronExpression: string, - jobId: string + jobId: string, ): Promise { await this.queue.add(jobType, payload, { repeat: { @@ -1009,7 +1192,7 @@ export class JobQueueService { async removeRepeatableJob( jobType: string, cronExpression: string, - jobId: string + jobId: string, ): Promise { await this.queue.removeRepeatable(jobType, { cron: cronExpression, diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index f64fd6a..b4acb8f 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,7 +10,16 @@ import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Scheduler'); -export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves'; +export type ScheduledJobType = + | 'plex_library_scan' + | 'plex_recently_added_check' + | 'audible_refresh' + | 'retry_missing_torrents' + | 'retry_failed_imports' + | 'cleanup_seeded_torrents' + | 'monitor_rss_feeds' + | 'sync_goodreads_shelves' + | 'sync_hardcover_shelves'; export interface ScheduledJob { id: string; @@ -133,6 +142,13 @@ export class SchedulerService { enabled: true, // Enable by default payload: {}, }, + { + name: 'Sync Hardcover Lists', + type: 'sync_hardcover_shelves' as ScheduledJobType, + schedule: '0 */6 * * *', // Every 6 hours + enabled: true, // Enable by default + payload: {}, + }, ]; let created = 0; @@ -149,7 +165,9 @@ export class SchedulerService { data: defaultJob, }); created++; - logger.info(`Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`); + logger.info( + `Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`, + ); } } catch (error) { failed++; @@ -161,7 +179,9 @@ export class SchedulerService { } if (failed > 0) { - logger.warn(`Default jobs: ${created} created, ${failed} failed — failed jobs will be retried on next restart`); + logger.warn( + `Default jobs: ${created} created, ${failed} failed — failed jobs will be retried on next restart`, + ); } else if (created > 0) { logger.info(`Default jobs: ${created} created`); } @@ -191,11 +211,13 @@ export class SchedulerService { job.type, { scheduledJobId: job.id }, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job scheduled: ${job.name} (${job.schedule})`); } catch (error) { - logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to schedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); throw error; } } @@ -208,11 +230,13 @@ export class SchedulerService { await this.jobQueue.removeRepeatableJob( job.type, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job unscheduled: ${job.name}`); } catch (error) { - logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to unschedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); // Don't throw - job might not exist in Bull yet } } @@ -264,7 +288,7 @@ export class SchedulerService { */ async updateScheduledJob( id: string, - dto: UpdateScheduledJobDto + dto: UpdateScheduledJobDto, ): Promise { if (dto.schedule) { this.validateCronExpression(dto.schedule); @@ -353,6 +377,9 @@ export class SchedulerService { case 'sync_goodreads_shelves': bullJobId = await this.triggerSyncGoodreadsShelves(job); break; + case 'sync_hardcover_shelves': + bullJobId = await this.triggerSyncHardcoverShelves(job); + break; default: throw new Error(`Unknown job type: ${job.type}`); } @@ -408,7 +435,8 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id']; + libraryId = + job.payload?.libraryId || absConfig['audiobookshelf.library_id']; } else { const plexConfig = await configService.getMany([ 'plex_url', @@ -432,15 +460,18 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id; + libraryId = + job.payload?.libraryId || plexConfig.plex_audiobook_library_id; } - logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`); + logger.info( + `Triggering ${backendMode} library scan for library: ${libraryId}`, + ); return await this.jobQueue.addPlexScanJob( libraryId || '', job.payload?.partial, - job.payload?.path + job.payload?.path, ); } @@ -461,7 +492,6 @@ export class SchedulerService { return await this.jobQueue.addAudibleRefreshJob(job.id); } - /** * Enable a scheduled job */ @@ -493,10 +523,12 @@ export class SchedulerService { await this.triggerJobNow(job.id); // Stagger triggers to avoid connection pool burst on startup - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } catch (error) { - logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to trigger overdue job "${job.name}"`, { + error: error instanceof Error ? error.message : String(error), + }); } } } @@ -569,13 +601,22 @@ export class SchedulerService { if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const hourNum = parseInt(hour, 10); const minuteNum = parseInt(minute, 10); - if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) { + if ( + !isNaN(hourNum) && + !isNaN(minuteNum) && + hourNum >= 0 && + hourNum <= 23 && + minuteNum >= 0 && + minuteNum <= 59 + ) { return 24 * 60 * 60 * 1000; // 24 hours } } // For other patterns, return a conservative default (24 hours) - logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`); + logger.warn( + `Unknown cron pattern "${cronExpression}", defaulting to 24 hours`, + ); return 24 * 60 * 60 * 1000; } @@ -627,6 +668,13 @@ export class SchedulerService { private async triggerSyncGoodreadsShelves(job: any): Promise { return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id); } + + /** + * Trigger Hardcover lists sync + */ + private async triggerSyncHardcoverShelves(job: any): Promise { + return await this.jobQueue.addSyncHardcoverShelvesJob(job.id); + } } // Singleton instance From 41d45d12101ed63515261ab16e6dc258bbcdbc88 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Fri, 27 Feb 2026 15:46:10 -0700 Subject: [PATCH 02/19] Refactor shelves UI and jobs --- src/app/api/user/goodreads-shelves/route.ts | 104 +++-- src/app/api/user/hardcover-shelves/route.ts | 2 +- src/app/api/user/shelves/route.ts | 99 +++++ src/app/profile/page.tsx | 10 +- .../profile/GoodreadsShelvesSection.tsx | 360 ------------------ ...rShelvesSection.tsx => ShelvesSection.tsx} | 195 +++++++--- src/lib/hooks/useShelves.ts | 40 ++ .../sync-goodreads-shelves.processor.ts | 42 -- .../sync-hardcover-shelves.processor.ts | 46 --- src/lib/processors/sync-shelves.processor.ts | 96 +++++ src/lib/services/job-queue.service.ts | 69 +--- src/lib/services/scheduler.service.ts | 67 ++-- 12 files changed, 511 insertions(+), 619 deletions(-) create mode 100644 src/app/api/user/shelves/route.ts delete mode 100644 src/components/profile/GoodreadsShelvesSection.tsx rename src/components/profile/{HardcoverShelvesSection.tsx => ShelvesSection.tsx} (69%) create mode 100644 src/lib/hooks/useShelves.ts delete mode 100644 src/lib/processors/sync-goodreads-shelves.processor.ts delete mode 100644 src/lib/processors/sync-hardcover-shelves.processor.ts create mode 100644 src/lib/processors/sync-shelves.processor.ts diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 9736619..b598349 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -16,10 +16,13 @@ const logger = RMABLogger.create('API.GoodreadsShelves'); const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; const AddShelfSchema = z.object({ - rssUrl: z.string().url().refine( - (url) => GOODREADS_RSS_PATTERN.test(url), - { message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' } - ), + rssUrl: z + .string() + .url() + .refine((url) => GOODREADS_RSS_PATTERN.test(url), { + message: + 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)', + }), }); /** @@ -40,7 +43,12 @@ export async function GET(request: NextRequest) { const shelvesWithMeta = shelves.map((shelf) => { // Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[]) - let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = []; + let books: { + coverUrl: string; + asin: string | null; + title: string; + author: string; + }[] = []; if (shelf.coverUrls) { const parsed = JSON.parse(shelf.coverUrls); if (Array.isArray(parsed)) { @@ -72,8 +80,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: true, shelves: shelvesWithMeta }); } catch (error) { - logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) }); - return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 }); + logger.error('Failed to list shelves', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list shelves' }, + { status: 500 }, + ); } }); } @@ -99,30 +112,43 @@ export async function POST(request: NextRequest) { if (existing) { return NextResponse.json( - { error: 'DuplicateShelf', message: 'You have already added this shelf' }, - { status: 409 } + { + error: 'DuplicateShelf', + message: 'You have already added this shelf', + }, + { status: 409 }, ); } // Validate by fetching the RSS feed let shelfName: string; let bookCount: number; - let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = []; + let initialBooks: { + coverUrl: string; + asin: null; + title: string; + author: string; + }[] = []; try { const rssData = await fetchAndValidateRss(rssUrl); shelfName = rssData.shelfName; bookCount = rssData.books.length; initialBooks = rssData.books - .filter(b => b.coverUrl) + .filter((b) => b.coverUrl) .slice(0, 8) - .map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author })); + .map((b) => ({ + coverUrl: b.coverUrl!, + asin: null, + title: b.title, + author: b.author, + })); } catch (error) { return NextResponse.json( { error: 'InvalidRSS', message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`, }, - { status: 400 } + { status: 400 }, ); } @@ -132,43 +158,55 @@ export async function POST(request: NextRequest) { name: shelfName, rssUrl, bookCount, - coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, + coverUrls: + initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, }, }); - // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0); - logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0); + logger.info( + `Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`, + ); } catch (error) { - logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to trigger immediate shelf sync', { + error: error instanceof Error ? error.message : String(error), + }); } - return NextResponse.json({ - success: true, - shelf: { - id: shelf.id, - name: shelf.name, - rssUrl: shelf.rssUrl, - lastSyncAt: shelf.lastSyncAt, - createdAt: shelf.createdAt, - bookCount: shelf.bookCount, - books: initialBooks, + return NextResponse.json( + { + success: true, + shelf: { + id: shelf.id, + name: shelf.name, + rssUrl: shelf.rssUrl, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount, + books: initialBooks, + }, + bookCount, }, - bookCount, - }, { status: 201 }); + { status: 201 }, + ); } catch (error) { - logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to add shelf', { + error: error instanceof Error ? error.message : String(error), + }); if (error instanceof z.ZodError) { return NextResponse.json( { error: 'ValidationError', details: error.errors }, - { status: 400 } + { status: 400 }, ); } - return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 }); + return NextResponse.json( + { error: 'Failed to add shelf' }, + { status: 500 }, + ); } }); } diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index c1489b3..7e07213 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -159,7 +159,7 @@ export async function POST(request: NextRequest) { // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncHardcoverShelvesJob(undefined, shelf.id, 0); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0); logger.info( `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, ); diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts new file mode 100644 index 0000000..93419df --- /dev/null +++ b/src/app/api/user/shelves/route.ts @@ -0,0 +1,99 @@ +/** + * Component: Combined Shelves API Routes + * Documentation: documentation/backend/services/goodreads-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Shelves'); + +/** + * GET /api/user/shelves + * List the current user's shelves (Goodreads, Hardcover) with book counts and covers + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const [goodreads, hardcover] = await Promise.all([ + prisma.goodreadsShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + prisma.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const processBooks = (coverUrls: string | null) => { + let books: { + coverUrl: string; + asin: string | null; + title: string; + author: string; + }[] = []; + if (coverUrls) { + const parsed = JSON.parse(coverUrls); + if (Array.isArray(parsed)) { + books = parsed.map((item: unknown) => { + if (typeof item === 'string') { + return { coverUrl: item, asin: null, title: '', author: '' }; + } + const obj = item as Record; + return { + coverUrl: (obj.coverUrl as string) || '', + asin: (obj.asin as string) || null, + title: (obj.title as string) || '', + author: (obj.author as string) || '', + }; + }); + } + } + return books; + }; + + const combined = [ + ...goodreads.map((s) => ({ + id: s.id, + type: 'goodreads', + name: s.name, + sourceId: s.rssUrl, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ...hardcover.map((s) => ({ + id: s.id, + type: 'hardcover', + name: s.name, + sourceId: s.listId, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return NextResponse.json({ success: true, shelves: combined }); + } catch (error) { + logger.error('Failed to list shelves', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list shelves' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 19ad798..6fcd163 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,8 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard'; import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; -import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; -import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection'; +import { ShelvesSection } from '@/components/profile/ShelvesSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -179,11 +178,8 @@ export default function ProfilePage() {
- {/* Goodreads Shelves */} - - - {/* Hardcover Lists */} - + {/* Generic Shelves Section */} + {/* Active Downloads */} {activeDownloads.length > 0 && ( diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/GoodreadsShelvesSection.tsx deleted file mode 100644 index 0b8d5e5..0000000 --- a/src/components/profile/GoodreadsShelvesSection.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/** - * Component: Goodreads Shelves Section (Profile Page) - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState } from 'react'; -import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; -import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; -import { usePreferences } from '@/contexts/PreferencesContext'; -import { cn } from '@/lib/utils/cn'; - -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return 'Never'; - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; -} - -export function GoodreadsShelvesSection() { - const { shelves, isLoading } = useGoodreadsShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf(); - const { squareCovers } = usePreferences(); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); - const [selectedAsin, setSelectedAsin] = useState(null); - - const handleDelete = async (shelfId: string) => { - try { - await deleteShelf(shelfId); - setConfirmDeleteId(null); - } catch { - // Error handled by hook - } - }; - - return ( -
- {/* Section Header */} -
-
-
- - - -
-
-

- Goodreads Shelves -

- {!isLoading && shelves.length > 0 && ( -

- {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected -

- )} -
-
- - -
- - {/* Content */} - {isLoading ? ( - - ) : shelves.length > 0 ? ( -
- {shelves.map((shelf) => ( - handleDelete(shelf.id)} - onConfirmDelete={() => setConfirmDeleteId(shelf.id)} - onCancelDelete={() => setConfirmDeleteId(null)} - onBookClick={(asin) => setSelectedAsin(asin)} - /> - ))} -
- ) : ( - setShowAddModal(true)} /> - )} - - setShowAddModal(false)} - /> - - {/* Audiobook Detail Modal (read-only) */} - {selectedAsin && ( - setSelectedAsin(null)} - hideRequestActions - /> - )} -
- ); -} - -/* ─── Empty State ─── */ - -function EmptyState({ onAdd }: { onAdd: () => void }) { - return ( -
-
- - - -
- -

- Connect your reading list -

-

- Link a Goodreads shelf and we'll automatically request the audiobook for every book you add. -

- - -
- ); -} - -/* ─── Loading Skeleton ─── */ - -function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { - return ( -
-
-
-
-
-
-
-
-
- {[...Array(5)].map((_, i) => ( -
0 ? '-16px' : 0, zIndex: 5 - i }} - /> - ))} -
-
- ); -} - -/* ─── Shelf Card ─── */ - -interface ShelfCardProps { - shelf: GoodreadsShelf; - squareCovers: boolean; - isDeleting: boolean; - isConfirmingDelete: boolean; - onDelete: () => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - onBookClick: (asin: string) => void; -} - -function ShelfCard({ - shelf, - squareCovers, - isDeleting, - isConfirmingDelete, - onDelete, - onConfirmDelete, - onCancelDelete, - onBookClick, -}: ShelfCardProps) { - const displayBooks = shelf.books.slice(0, 6); - const hasCovers = displayBooks.length > 0; - const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length); - const isSyncing = !shelf.lastSyncAt; - - return ( -
- {/* Top: Shelf info + actions */} -
-
-

- {shelf.name} -

-
- {shelf.bookCount != null && ( - - {shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'} - - )} - - {isSyncing ? ( - <> - - - - - Syncing… - - ) : shelf.lastSyncAt ? ( - <> - - Synced {formatRelativeTime(shelf.lastSyncAt)} - - ) : ( - 'Pending sync' - )} - -
-
- - {/* Delete action */} -
- {isConfirmingDelete ? ( -
- - -
- ) : ( - - )} -
-
- - {/* Bottom: Stacked book covers */} - {hasCovers ? ( - - ) : isSyncing ? ( -
- {[...Array(3)].map((_, i) => ( -
0 ? '-16px' : 0, zIndex: 3 - i }} - /> - ))} -
- ) : null} -
- ); -} - -/* ─── Stacked Cover Display ─── */ - -function CoverStack({ - books, - remainingCount, - squareCovers, - onBookClick, -}: { - books: ShelfBook[]; - remainingCount: number; - squareCovers: boolean; - onBookClick: (asin: string) => void; -}) { - const [hoveredIndex, setHoveredIndex] = useState(null); - const coverSize = squareCovers - ? 'w-[80px] aspect-square' - : 'w-[72px] aspect-[2/3]'; - - return ( -
- {books.map((book, i) => ( -
0 ? '-16px' : 0, - zIndex: hoveredIndex === i ? 50 : books.length - i, - }} - onMouseEnter={() => setHoveredIndex(i)} - onMouseLeave={() => setHoveredIndex(null)} - onClick={() => book.asin && onBookClick(book.asin)} - title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} - > - -
- ))} - {remainingCount > 0 && ( -
- - +{remainingCount} - -
- )} -
- ); -} diff --git a/src/components/profile/HardcoverShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx similarity index 69% rename from src/components/profile/HardcoverShelvesSection.tsx rename to src/components/profile/ShelvesSection.tsx index 67abc15..a84ec11 100644 --- a/src/components/profile/HardcoverShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -1,21 +1,21 @@ /** - * Component: Hardcover Shelves Section (Profile Page) + * Component: Combined Shelves Section (Profile Page) * Documentation: documentation/frontend/components.md */ 'use client'; import React, { useState } from 'react'; -import { - useHardcoverShelves, - useDeleteHardcoverShelf, - HardcoverShelf, - ShelfBook, -} from '@/lib/hooks/useHardcoverShelves'; +import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; +import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; +import { Modal } from '@/components/ui/Modal'; +import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return 'Never'; @@ -31,31 +31,43 @@ function formatRelativeTime(dateStr: string | null): string { return `${diffDays}d ago`; } -export function HardcoverShelvesSection() { - const { shelves, isLoading } = useHardcoverShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteHardcoverShelf(); +export function ShelvesSection() { + const { shelves, isLoading } = useShelves(); + const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } = + useDeleteGoodreadsShelf(); + const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = + useDeleteHardcoverShelf(); const { squareCovers } = usePreferences(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); + const [showProviderSelect, setShowProviderSelect] = useState(false); + const [showAddGoodreads, setShowAddGoodreads] = useState(false); + const [showAddHardcover, setShowAddHardcover] = useState(false); const [selectedAsin, setSelectedAsin] = useState(null); - const handleDelete = async (shelfId: string) => { + const handleDelete = async (shelf: GenericShelf) => { try { - await deleteShelf(shelfId); + if (shelf.type === 'goodreads') { + await deleteGoodreads(shelf.id); + } else { + await deleteHardcover(shelf.id); + } setConfirmDeleteId(null); } catch { // Error handled by hook } }; + const isDeleting = isDeletingGoodreads || isDeletingHardcover; + return (
{/* Section Header */}
-
+

- Hardcover Lists + Shelves

{!isLoading && shelves.length > 0 && (

- {shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '} + {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '} connected

)}
- + + + + Add Shelf + + )}
{/* Content */} @@ -114,7 +128,7 @@ export function HardcoverShelvesSection() { squareCovers={squareCovers} isDeleting={isDeleting && confirmDeleteId === shelf.id} isConfirmingDelete={confirmDeleteId === shelf.id} - onDelete={() => handleDelete(shelf.id)} + onDelete={() => handleDelete(shelf)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} onBookClick={(asin) => setSelectedAsin(asin)} @@ -122,15 +136,30 @@ export function HardcoverShelvesSection() { ))}
) : ( - setShowAddModal(true)} /> + setShowProviderSelect(true)} /> )} - setShowAddModal(false)} + {/* Modals */} + setShowProviderSelect(false)} + onSelect={(provider) => { + setShowProviderSelect(false); + if (provider === 'goodreads') setShowAddGoodreads(true); + else if (provider === 'hardcover') setShowAddHardcover(true); + }} + /> + + setShowAddGoodreads(false)} + /> + + setShowAddHardcover(false)} /> - {/* Audiobook Detail Modal (read-only) */} {selectedAsin && ( void; + onSelect: (provider: string) => void; +}) { + return ( + +
+ + +
+
+ ); +} + /* ─── Empty State ─── */ function EmptyState({ onAdd }: { onAdd: () => void }) { return (
-
+
void }) { Connect your reading list

- Link a Hardcover list and we'll automatically request the audiobook for - every book you add. + Link a Goodreads or Hardcover shelf and we'll automatically request the + audiobook for every book you add.

); @@ -226,7 +310,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { /* ─── Shelf Card ─── */ interface ShelfCardProps { - shelf: HardcoverShelf; + shelf: GenericShelf; squareCovers: boolean; isDeleting: boolean; isConfirmingDelete: boolean; @@ -254,6 +338,17 @@ function ShelfCard({ ); const isSyncing = !shelf.lastSyncAt; + const providerIcon = + shelf.type === 'goodreads' ? ( + + g + + ) : ( + + H + + ); + return (
{/* Top: Shelf info + actions */} @@ -264,8 +359,8 @@ function ShelfCard({ )} >
-

- {shelf.name} +

+ {shelf.name} {providerIcon}

{shelf.bookCount != null && ( @@ -317,7 +412,7 @@ function ShelfCard({
); diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index a84ec11..afe7879 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -9,8 +9,7 @@ import React, { useState } from 'react'; import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; -import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal'; +import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; @@ -40,9 +39,7 @@ export function ShelvesSection() { const { squareCovers } = usePreferences(); const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showProviderSelect, setShowProviderSelect] = useState(false); - const [showAddGoodreads, setShowAddGoodreads] = useState(false); - const [showAddHardcover, setShowAddHardcover] = useState(false); + const [showAddShelf, setShowAddShelf] = useState(false); const [selectedAsin, setSelectedAsin] = useState(null); const handleDelete = async (shelf: GenericShelf) => { @@ -95,7 +92,7 @@ export function ShelvesSection() { {shelves.length > 0 && ( - - - - ); -} - /* ─── Empty State ─── */ function EmptyState({ onAdd }: { onAdd: () => void }) { diff --git a/src/components/ui/AddGoodreadsShelfModal.tsx b/src/components/ui/AddGoodreadsShelfModal.tsx deleted file mode 100644 index dd0489b..0000000 --- a/src/components/ui/AddGoodreadsShelfModal.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Component: Add Goodreads Shelf Modal - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState } from 'react'; -import { Modal } from './Modal'; -import { Input } from './Input'; -import { Button } from './Button'; -import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; - -interface AddGoodreadsShelfModalProps { - isOpen: boolean; - onClose: () => void; -} - -const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; - -export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) { - const [rssUrl, setRssUrl] = useState(''); - const [validationError, setValidationError] = useState(''); - const [success, setSuccess] = useState(false); - const [successMessage, setSuccessMessage] = useState(''); - const { addShelf, isLoading, error } = useAddGoodreadsShelf(); - - const validateUrl = (url: string): boolean => { - if (!url.trim()) { - setValidationError('RSS URL is required'); - return false; - } - if (!GOODREADS_RSS_PATTERN.test(url)) { - setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)'); - return false; - } - setValidationError(''); - return true; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateUrl(rssUrl)) return; - - try { - const shelf = await addShelf(rssUrl); - setSuccess(true); - setSuccessMessage(`Added shelf "${shelf.name}" successfully!`); - setRssUrl(''); - - setTimeout(() => { - setSuccess(false); - onClose(); - }, 2000); - } catch { - // Error is handled by the hook - } - }; - - const handleClose = () => { - setRssUrl(''); - setValidationError(''); - setSuccess(false); - setSuccessMessage(''); - onClose(); - }; - - return ( - -
- {/* Visual header */} -
-
- - - -
-
-

- Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync. -

-
-
- - {/* Success alert */} - {success && ( -
-
- - - -
-

{successMessage}

-
- )} - - {/* Error alert */} - {error && ( -
-
- - - -
-

{error}

-
- )} - - {/* Form */} -
-
- { - setRssUrl(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="https://www.goodreads.com/review/list_rss/..." - error={validationError} - disabled={isLoading || success} - /> -

- Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page. -

-
- -
- - -
-
-
-
- ); -} diff --git a/src/components/ui/AddHardcoverShelfModal.tsx b/src/components/ui/AddHardcoverShelfModal.tsx deleted file mode 100644 index 6ac45f7..0000000 --- a/src/components/ui/AddHardcoverShelfModal.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Component: Add Hardcover Shelf Modal - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState } from 'react'; -import { Modal } from './Modal'; -import { Input } from './Input'; -import { Button } from './Button'; -import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; - -interface AddHardcoverShelfModalProps { - isOpen: boolean; - onClose: () => void; -} - -export function AddHardcoverShelfModal({ - isOpen, - onClose, -}: AddHardcoverShelfModalProps) { - const [apiToken, setApiToken] = useState(''); - const [listId, setListId] = useState(''); - const [validationError, setValidationError] = useState(''); - const [success, setSuccess] = useState(false); - const [successMessage, setSuccessMessage] = useState(''); - const { addShelf, isLoading, error } = useAddHardcoverShelf(); - - const validateInput = (): boolean => { - if (!apiToken.trim()) { - setValidationError('Hardcover API Token is required'); - return false; - } - if (!listId.trim()) { - setValidationError('Hardcover List ID or Status ID is required'); - return false; - } - setValidationError(''); - return true; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateInput()) return; - - try { - const shelf = await addShelf(apiToken.trim(), listId.trim()); - setSuccess(true); - setSuccessMessage(`Added list "${shelf.name}" successfully!`); - setApiToken(''); - setListId(''); - - setTimeout(() => { - setSuccess(false); - onClose(); - }, 2000); - } catch { - // Error is handled by the hook - } - }; - - const handleClose = () => { - setApiToken(''); - setListId(''); - setValidationError(''); - setSuccess(false); - setSuccessMessage(''); - onClose(); - }; - - return ( - -
- {/* Visual header */} -
-
- - - -
-
-

- Provides your Hardcover API token and the ID of the list you want - to sync. -

-
-
- - {/* Success alert */} - {success && ( -
-
- - - -
-

- {successMessage} -

-
- )} - - {/* Error alert */} - {error && ( -
-
- - - -
-

- {error} -

-
- )} - - {/* Form */} -
-
- { - setApiToken(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="eyJhb..." - disabled={isLoading || success} - /> - { - setListId(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="1234 or uuid" - error={validationError} - disabled={isLoading || success} - /> -
- -
- - -
-
-
-
- ); -} diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx new file mode 100644 index 0000000..de42246 --- /dev/null +++ b/src/components/ui/AddShelfModal.tsx @@ -0,0 +1,382 @@ +/** + * Component: Add Shelf Modal + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { Input } from './Input'; +import { Button } from './Button'; +import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; + +interface AddShelfModalProps { + isOpen: boolean; + onClose: () => void; +} + +const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; + +export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { + const [provider, setProvider] = useState<'goodreads' | 'hardcover'>( + 'goodreads', + ); + + // Goodreads State + const [rssUrl, setRssUrl] = useState(''); + + // Hardcover State + const [apiToken, setApiToken] = useState(''); + const [listType, setListType] = useState<'status' | 'custom'>('status'); + const [statusId, setStatusId] = useState('1'); // 1 = Want to Read + const [customListId, setCustomListId] = useState(''); + + const [validationError, setValidationError] = useState(''); + const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const { + addShelf: addGoodreads, + isLoading: isGoodreadsLoading, + error: goodreadsError, + } = useAddGoodreadsShelf(); + const { + addShelf: addHardcover, + isLoading: isHardcoverLoading, + error: hardcoverError, + } = useAddHardcoverShelf(); + + const isLoading = isGoodreadsLoading || isHardcoverLoading; + const currentError = + provider === 'goodreads' ? goodreadsError : hardcoverError; + + const validateInput = (): boolean => { + if (provider === 'goodreads') { + if (!rssUrl.trim()) { + setValidationError('RSS URL is required'); + return false; + } + if (!GOODREADS_RSS_PATTERN.test(rssUrl)) { + setValidationError( + 'Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)', + ); + return false; + } + } else { + if (!apiToken.trim()) { + setValidationError('Hardcover API Token is required'); + return false; + } + if (listType === 'custom' && !customListId.trim()) { + setValidationError('Hardcover List URL or Slug is required'); + return false; + } + } + setValidationError(''); + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateInput()) return; + + try { + if (provider === 'goodreads') { + const shelf = await addGoodreads(rssUrl); + setSuccessMessage(`Added shelf "${shelf.name}" successfully!`); + setRssUrl(''); + } else { + const finalId = + listType === 'status' ? `status-${statusId}` : customListId.trim(); + let cleanedToken = apiToken.trim(); + if (cleanedToken.toLowerCase().startsWith('bearer ')) { + cleanedToken = cleanedToken.slice(7).trim(); + } + const shelf = await addHardcover(cleanedToken, finalId); + setSuccessMessage(`Added list "${shelf.name}" successfully!`); + setApiToken(''); + setCustomListId(''); + } + + setSuccess(true); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } catch { + // Error is handled by the hooks + } + }; + + const handleClose = () => { + setRssUrl(''); + setApiToken(''); + setCustomListId(''); + setValidationError(''); + setSuccess(false); + setSuccessMessage(''); + onClose(); + }; + + return ( + +
+ {/* Provider Selection Tabs */} +
+ + +
+ + {/* Visual header */} +
+ {provider === 'goodreads' ? ( + <> +
+ + + +
+
+

+ Paste your Goodreads shelf RSS URL. Books will be + automatically requested. +

+
+ + ) : ( + <> +
+ + + +
+
+

+ Provide your Hardcover API token and select the list you want + to sync. +

+
+ + )} +
+ + {/* Success alert */} + {success && ( +
+
+ + + +
+

+ {successMessage} +

+
+ )} + + {/* Error alert */} + {currentError && ( +
+
+ + + +
+

+ {currentError} +

+
+ )} + + {/* Form */} +
+ {provider === 'goodreads' ? ( +
+ { + setRssUrl(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="https://www.goodreads.com/review/list_rss/..." + error={validationError} + disabled={isLoading || success} + /> +

+ Find it on Goodreads: My Books → select a shelf → RSS + link at the bottom of the page. +

+
+ ) : ( +
+ { + setApiToken(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="eyJhb..." + disabled={isLoading || success} + /> + +
+ +
+ + +
+
+ + {listType === 'status' ? ( +
+ +
+ ) : ( + { + setCustomListId(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="https://hardcover.app/@username/lists/..." + error={validationError} + disabled={isLoading || success} + /> + )} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index 2149c40..3512f90 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -38,107 +38,210 @@ export async function fetchHardcoverList( apiToken: string, listIdStr: string, ): Promise<{ listName: string; books: HardcoverApiBook[] }> { - // If we can parse as integer, it could be a List ID or Status ID. If UUID, we adjust query - const isUuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - listIdStr, + // Check if it's a status list + const isStatus = listIdStr.startsWith('status-'); + + if (isStatus) { + const statusId = parseInt(listIdStr.replace('status-', ''), 10); + const query = ` + query GetStatusBooks($statusId: Int!) { + user_books(where: {status_id: {_eq: $statusId}}) { + book { + id + title + contributions { + author { + name + } + } + cached_image + image { + url + } + } + } + } + `; + + const response = await axios.post( + HARDCOVER_API_URL, + { query, variables: { statusId } }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, ); - // Example generic query to Hardcover. Adjust the table/format as needed for their schema. - // Hardcover lists use custom lists (list_books) or statuses (user_books). - // Assuming list_books for this implementation. - const query = ` - query GetListBooks($listId: Int!) { - list_books(where: {list_id: {_eq: $listId}}) { - list { - name - } - book { - id - title - author_books { - author { - name - } + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); + } + + const userBooks = response.data?.data?.user_books || []; + let listName = 'Hardcover Status List'; + + // Map status numbers to names + const statusNames: Record = { + 1: 'Want to Read', + 2: 'Currently Reading', + 3: 'Read', + 4: 'Did Not Finish', + }; + listName = statusNames[statusId] || `Status ${statusId}`; + + const books: HardcoverApiBook[] = []; + for (const item of userBooks) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const coverUrl = book.cached_image || book.image?.url || undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; + } else { + // Original list_books logic + let isUuid = false; + let isIntId = false; + let extractedSlug = listIdStr; + + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + listIdStr, + ) + ) { + isUuid = true; + } else if (/^\d+$/.test(listIdStr)) { + isIntId = true; + } else { + try { + if (listIdStr.includes('hardcover.app')) { + const url = new URL( + listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`, + ); + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length > 0) { + extractedSlug = parts[parts.length - 1]; } - cached_image - image { - url + } + } catch (e) { + // use extractedSlug as-is + } + } + + const query = ` + query GetListBooks($listId: Int!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { name } + book { + id title cached_image image { url } + contributions { author { name } } } } } - } - `; + `; - // Provide fallback UUID query if Hardcover uses UUIDs instead. - const queryUuid = ` - query GetListBooksUuid($listId: uuid!) { - list_books(where: {list_id: {_eq: $listId}}) { - list { - name - } - book { - id - title - author_books { - author { - name - } - } - cached_image - image { - url + const queryUuid = ` + query GetListBooksUuid($listId: uuid!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { name } + book { + id title cached_image image { url } + contributions { author { name } } } } } + `; + + const querySlug = ` + query GetListBooksBySlug($slug: String!) { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + name + list_books { + book { + id title cached_image image { url } + contributions { author { name } } + } + } + } + } + `; + + const isSlug = !isUuid && !isIntId; + const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query; + const variables = isSlug + ? { slug: extractedSlug } + : { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) }; + + const response = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); + + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); } - `; - const response = await axios.post( - HARDCOVER_API_URL, - { - query: isUuid ? queryUuid : query, - variables: { - listId: isUuid ? listIdStr : parseInt(listIdStr, 10), - }, - }, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - timeout: 15000, - }, - ); + let listName = 'Hardcover List'; + let listBooks: any[] = []; - if (response.data?.errors) { - throw new Error(`Hardcover API Error: ${response.data.errors[0]?.message}`); + if (isSlug) { + const listsData = response.data?.data?.lists || []; + if (listsData.length === 0) { + throw new Error(`Could not find a list with slug "${extractedSlug}"`); + } + listName = listsData[0].name || listName; + listBooks = listsData[0].list_books || []; + } else { + listBooks = response.data?.data?.list_books || []; + if (listBooks.length > 0 && listBooks[0].list?.name) { + listName = listBooks[0].list.name; + } + } + + const books: HardcoverApiBook[] = []; + for (const item of listBooks) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const coverUrl = book.cached_image || book.image?.url || undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; } - - const listBooks = response.data?.data?.list_books || []; - let listName = 'Hardcover List'; - if (listBooks.length > 0 && listBooks[0].list?.name) { - listName = listBooks[0].list.name; - } - - const books: HardcoverApiBook[] = []; - for (const item of listBooks) { - const book = item.book; - if (!book || !book.id) continue; - - // Hardcover authors can be multiple, we pick the first one or join them - const authorName = book.author_books?.[0]?.author?.name || 'Unknown Author'; - const coverUrl = book.cached_image || book.image?.url || undefined; - - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); - } - - return { listName, books }; } export interface HardcoverSyncStats { diff --git a/test_hardcover.js b/test_hardcover.js new file mode 100644 index 0000000..c6ba0d3 --- /dev/null +++ b/test_hardcover.js @@ -0,0 +1,12 @@ +const axios = require('axios'); +async function run() { + try { + const res = await axios.post('https://api.hardcover.app/v1/graphql', { + query: "{ __schema { types { name } } }" + }); + console.log(res.data); + } catch(e) { + console.log(e.response ? e.response.data : e.message); + } +} +run(); From e4e127880b6ac718198f035644c0ccb6bf0dfb5e Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Mon, 2 Mar 2026 21:12:34 -0700 Subject: [PATCH 07/19] fix modal --- src/components/ui/Modal.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index baecde5..449e25d 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -5,7 +5,8 @@ 'use client'; -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils/cn'; interface ModalProps { @@ -25,6 +26,12 @@ export function Modal({ size = 'md', showCloseButton = true, }: ModalProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + // Use ref to avoid re-running effect when onClose changes const onCloseRef = useRef(onClose); onCloseRef.current = onClose; @@ -53,7 +60,7 @@ export function Modal({ }; }, [isOpen, handleClose]); - if (!isOpen) return null; + if (!isOpen || !mounted) return null; const sizeClasses = { sm: 'max-w-md', @@ -63,8 +70,8 @@ export function Modal({ full: 'max-w-[95vw]', }; - return ( -
+ const content = ( +
{/* Backdrop */}
e.stopPropagation()} > @@ -116,4 +123,6 @@ export function Modal({
); + + return createPortal(content, document.body); } From 225ef8c9195768fef45346594cbfb5ed29b96649 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 11:38:30 -0700 Subject: [PATCH 08/19] Fix import to limit to 100, and scope to me for personal lists --- src/lib/services/hardcover-sync.service.ts | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index 3512f90..53f08ea 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -45,18 +45,20 @@ export async function fetchHardcoverList( const statusId = parseInt(listIdStr.replace('status-', ''), 10); const query = ` query GetStatusBooks($statusId: Int!) { - user_books(where: {status_id: {_eq: $statusId}}) { - book { - id - title - contributions { - author { - name + me { + user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) { + book { + id + title + contributions { + author { + name + } + } + cached_image + image { + url } - } - cached_image - image { - url } } } @@ -81,7 +83,7 @@ export async function fetchHardcoverList( ); } - const userBooks = response.data?.data?.user_books || []; + const userBooks = response.data?.data?.me?.[0]?.user_books || []; let listName = 'Hardcover Status List'; // Map status numbers to names @@ -143,7 +145,7 @@ export async function fetchHardcoverList( const query = ` query GetListBooks($listId: Int!) { - list_books(where: {list_id: {_eq: $listId}}) { + list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) { list { name } book { id title cached_image image { url } @@ -155,7 +157,7 @@ export async function fetchHardcoverList( const queryUuid = ` query GetListBooksUuid($listId: uuid!) { - list_books(where: {list_id: {_eq: $listId}}) { + list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) { list { name } book { id title cached_image image { url } @@ -169,7 +171,7 @@ export async function fetchHardcoverList( query GetListBooksBySlug($slug: String!) { lists(where: {slug: {_eq: $slug}}, limit: 1) { name - list_books { + list_books(limit: 100, order_by: {id: desc}) { book { id title cached_image image { url } contributions { author { name } } From 4ae68d01de53115602a2602b9e0572e8db04f4bd Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 11:51:38 -0700 Subject: [PATCH 09/19] Encrypt Hardcover Api Token and fix failing tests --- src/app/api/user/hardcover-shelves/route.ts | 6 ++- src/lib/services/hardcover-sync.service.ts | 16 ++++++- src/lib/services/scheduler.service.ts | 52 ++++++++++++++------- test_hardcover.js | 12 ----- tests/services/job-queue.service.test.ts | 8 ++-- tests/services/scheduler.service.test.ts | 17 ++++--- 6 files changed, 71 insertions(+), 40 deletions(-) delete mode 100644 test_hardcover.js diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index b6ec6a8..4390220 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service'; import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; @@ -150,12 +151,15 @@ export async function POST(request: NextRequest) { ); } + const encryptionService = getEncryptionService(); + const encryptedToken = encryptionService.encrypt(apiToken); + const shelf = await prisma.hardcoverShelf.create({ data: { userId: req.user.id, name: listName, listId, - apiToken, + apiToken: encryptedToken, bookCount, coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index 53f08ea..e644d14 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -10,6 +10,7 @@ import axios from 'axios'; import { prisma } from '@/lib/db'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('HardcoverSync'); @@ -330,9 +331,22 @@ async function processShelf( `Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`, ); + const encryptionService = getEncryptionService(); + let decryptedToken = shelf.apiToken; + try { + // Check if the token is encrypted (our new storage method format) + if (encryptionService.isEncryptedFormat(shelf.apiToken)) { + decryptedToken = encryptionService.decrypt(shelf.apiToken); + } + } catch (err) { + log.error( + `Failed to decrypt API token for user ${shelf.user.plexUsername}`, + ); + } + let fetchedData: { listName: string; books: HardcoverApiBook[] }; try { - fetchedData = await fetchHardcoverList(shelf.apiToken, shelf.listId); + fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId); } catch (error) { log.error( `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index 7b1b2eb..7281376 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -189,7 +189,9 @@ export class SchedulerService { await this.unscheduleJob(job); } await prisma.scheduledJob.delete({ where: { id: job.id } }); - logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`); + logger.info( + `Removed deprecated scheduled job: ${job.name} (${job.type})`, + ); } } catch (error) { logger.error('Failed to cleanup deprecated scheduled jobs', { @@ -222,11 +224,13 @@ export class SchedulerService { job.type, { scheduledJobId: job.id }, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job scheduled: ${job.name} (${job.schedule})`); } catch (error) { - logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to schedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); throw error; } } @@ -239,11 +243,13 @@ export class SchedulerService { await this.jobQueue.removeRepeatableJob( job.type, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job unscheduled: ${job.name}`); } catch (error) { - logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to unschedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); // Don't throw - job might not exist in Bull yet } } @@ -295,7 +301,7 @@ export class SchedulerService { */ async updateScheduledJob( id: string, - dto: UpdateScheduledJobDto + dto: UpdateScheduledJobDto, ): Promise { if (dto.schedule) { this.validateCronExpression(dto.schedule); @@ -439,7 +445,8 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id']; + libraryId = + job.payload?.libraryId || absConfig['audiobookshelf.library_id']; } else { const plexConfig = await configService.getMany([ 'plex_url', @@ -463,15 +470,18 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id; + libraryId = + job.payload?.libraryId || plexConfig.plex_audiobook_library_id; } - logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`); + logger.info( + `Triggering ${backendMode} library scan for library: ${libraryId}`, + ); return await this.jobQueue.addPlexScanJob( libraryId || '', job.payload?.partial, - job.payload?.path + job.payload?.path, ); } @@ -492,7 +502,6 @@ export class SchedulerService { return await this.jobQueue.addAudibleRefreshJob(job.id); } - /** * Enable a scheduled job */ @@ -524,10 +533,12 @@ export class SchedulerService { await this.triggerJobNow(job.id); // Stagger triggers to avoid connection pool burst on startup - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } catch (error) { - logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to trigger overdue job "${job.name}"`, { + error: error instanceof Error ? error.message : String(error), + }); } } } @@ -600,13 +611,22 @@ export class SchedulerService { if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const hourNum = parseInt(hour, 10); const minuteNum = parseInt(minute, 10); - if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) { + if ( + !isNaN(hourNum) && + !isNaN(minuteNum) && + hourNum >= 0 && + hourNum <= 23 && + minuteNum >= 0 && + minuteNum <= 59 + ) { return 24 * 60 * 60 * 1000; // 24 hours } } // For other patterns, return a conservative default (24 hours) - logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`); + logger.warn( + `Unknown cron pattern "${cronExpression}", defaulting to 24 hours`, + ); return 24 * 60 * 60 * 1000; } @@ -656,7 +676,7 @@ export class SchedulerService { * Trigger Reading shelves sync */ private async triggerSyncShelves(job: any): Promise { - return await this.jobQueue.addSyncShelvesJob(job.id, undefined, 'goodreads'); + return await this.jobQueue.addSyncShelvesJob(job.id); } } diff --git a/test_hardcover.js b/test_hardcover.js deleted file mode 100644 index c6ba0d3..0000000 --- a/test_hardcover.js +++ /dev/null @@ -1,12 +0,0 @@ -const axios = require('axios'); -async function run() { - try { - const res = await axios.post('https://api.hardcover.app/v1/graphql', { - query: "{ __schema { types { name } } }" - }); - console.log(res.data); - } catch(e) { - console.log(e.response ? e.response.data : e.message); - } -} -run(); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index 78e3998..922584c 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -21,7 +21,7 @@ const processorsMock = vi.hoisted(() => ({ processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), - processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'), + processSyncShelves: vi.fn().mockResolvedValue('ok'), // Ebook processors processSearchEbook: vi.fn().mockResolvedValue('ok'), processStartDirectDownload: vi.fn().mockResolvedValue('ok'), @@ -116,8 +116,8 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, })); -vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({ - processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, +vi.mock('@/lib/processors/sync-shelves.processor', () => ({ + processSyncShelves: processorsMock.processSyncShelves, })); // Ebook processors @@ -564,7 +564,7 @@ describe('JobQueueService', () => { expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); - expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled(); + expect(processorsMock.processSyncShelves).toHaveBeenCalled(); }); it('returns repeatable jobs from the queue', async () => { diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index a64b021..bd3b0f2 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,7 +18,8 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), - addSyncGoodreadsShelvesJob: vi.fn(), + addMonitorRssFeedsJob: vi.fn(), + addSyncShelvesJob: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ @@ -63,7 +64,9 @@ describe('SchedulerService', () => { prismaMock.scheduledJob.findFirst.mockResolvedValue(null); prismaMock.scheduledJob.create.mockResolvedValue({}); prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([]) // cleanupDeprecatedJobs .mockResolvedValueOnce([ + // scheduleAllJobs { id: 'job-1', name: 'Audible Data Refresh', @@ -72,9 +75,10 @@ describe('SchedulerService', () => { enabled: true, }, ]) - .mockResolvedValueOnce([]); + .mockResolvedValue([]); // triggerOverdueJobs - const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const { SchedulerService } = + await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); await service.start(); @@ -83,7 +87,7 @@ describe('SchedulerService', () => { 'audible_refresh', { scheduledJobId: 'job-1' }, '0 0 * * *', - 'scheduled-job-1' + 'scheduled-job-1', ); }); @@ -289,7 +293,7 @@ describe('SchedulerService', () => { ['retry_failed_imports', 'addRetryFailedImportsJob'], ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], ['monitor_rss_feeds', 'addMonitorRssFeedsJob'], - ['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'], + ['sync_reading_shelves', 'addSyncShelvesJob'], ])('triggers %s jobs with job queue', async (type, queueMethod) => { prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-type', @@ -302,7 +306,8 @@ describe('SchedulerService', () => { (jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type'); prismaMock.scheduledJob.update.mockResolvedValue({}); - const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const { SchedulerService } = + await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); const jobId = await service.triggerJobNow('job-type'); From 8f8387abff86cf2871ac47926e47b23365fc22ef Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 12:19:12 -0700 Subject: [PATCH 10/19] token encryption --- src/lib/services/scheduler.service.ts | 50 +++++++----------------- tests/services/scheduler.service.test.ts | 8 ++-- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index 7281376..ccd6bed 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -189,9 +189,7 @@ export class SchedulerService { await this.unscheduleJob(job); } await prisma.scheduledJob.delete({ where: { id: job.id } }); - logger.info( - `Removed deprecated scheduled job: ${job.name} (${job.type})`, - ); + logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`); } } catch (error) { logger.error('Failed to cleanup deprecated scheduled jobs', { @@ -224,13 +222,11 @@ export class SchedulerService { job.type, { scheduledJobId: job.id }, job.schedule, - `scheduled-${job.id}`, + `scheduled-${job.id}` ); logger.info(`Job scheduled: ${job.name} (${job.schedule})`); } catch (error) { - logger.error(`Failed to schedule job ${job.name}`, { - error: error instanceof Error ? error.message : String(error), - }); + logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); throw error; } } @@ -243,13 +239,11 @@ export class SchedulerService { await this.jobQueue.removeRepeatableJob( job.type, job.schedule, - `scheduled-${job.id}`, + `scheduled-${job.id}` ); logger.info(`Job unscheduled: ${job.name}`); } catch (error) { - logger.error(`Failed to unschedule job ${job.name}`, { - error: error instanceof Error ? error.message : String(error), - }); + logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); // Don't throw - job might not exist in Bull yet } } @@ -301,7 +295,7 @@ export class SchedulerService { */ async updateScheduledJob( id: string, - dto: UpdateScheduledJobDto, + dto: UpdateScheduledJobDto ): Promise { if (dto.schedule) { this.validateCronExpression(dto.schedule); @@ -445,8 +439,7 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = - job.payload?.libraryId || absConfig['audiobookshelf.library_id']; + libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id']; } else { const plexConfig = await configService.getMany([ 'plex_url', @@ -470,18 +463,15 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = - job.payload?.libraryId || plexConfig.plex_audiobook_library_id; + libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id; } - logger.info( - `Triggering ${backendMode} library scan for library: ${libraryId}`, - ); + logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`); return await this.jobQueue.addPlexScanJob( libraryId || '', job.payload?.partial, - job.payload?.path, + job.payload?.path ); } @@ -502,6 +492,7 @@ export class SchedulerService { return await this.jobQueue.addAudibleRefreshJob(job.id); } + /** * Enable a scheduled job */ @@ -533,12 +524,10 @@ export class SchedulerService { await this.triggerJobNow(job.id); // Stagger triggers to avoid connection pool burst on startup - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 500)); } } catch (error) { - logger.error(`Failed to trigger overdue job "${job.name}"`, { - error: error instanceof Error ? error.message : String(error), - }); + logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); } } } @@ -611,22 +600,13 @@ export class SchedulerService { if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const hourNum = parseInt(hour, 10); const minuteNum = parseInt(minute, 10); - if ( - !isNaN(hourNum) && - !isNaN(minuteNum) && - hourNum >= 0 && - hourNum <= 23 && - minuteNum >= 0 && - minuteNum <= 59 - ) { + if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) { return 24 * 60 * 60 * 1000; // 24 hours } } // For other patterns, return a conservative default (24 hours) - logger.warn( - `Unknown cron pattern "${cronExpression}", defaulting to 24 hours`, - ); + logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`); return 24 * 60 * 60 * 1000; } diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index bd3b0f2..ff852b7 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -77,8 +77,7 @@ describe('SchedulerService', () => { ]) .mockResolvedValue([]); // triggerOverdueJobs - const { SchedulerService } = - await import('@/lib/services/scheduler.service'); + const { SchedulerService } = await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); await service.start(); @@ -87,7 +86,7 @@ describe('SchedulerService', () => { 'audible_refresh', { scheduledJobId: 'job-1' }, '0 0 * * *', - 'scheduled-job-1', + 'scheduled-job-1' ); }); @@ -306,8 +305,7 @@ describe('SchedulerService', () => { (jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type'); prismaMock.scheduledJob.update.mockResolvedValue({}); - const { SchedulerService } = - await import('@/lib/services/scheduler.service'); + const { SchedulerService } = await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); const jobId = await service.triggerJobNow('job-type'); From c57d0c1492dfba50530d3341d99c39ee92fa576f Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 13:16:23 -0700 Subject: [PATCH 11/19] Add a manage shelf modal --- public/goodreads-icon.png | Bin 0 -> 1062 bytes public/hardcover-icon.svg | 1 + .../api/user/goodreads-shelves/[id]/route.ts | 60 ++++++++ .../api/user/hardcover-shelves/[id]/route.ts | 89 ++++++++++++ src/components/profile/ShelvesSection.tsx | 84 +++++++---- src/components/ui/AddShelfModal.tsx | 36 ++--- src/components/ui/ManageShelfModal.tsx | 136 ++++++++++++++++++ src/lib/hooks/useGoodreadsShelves.ts | 50 +++++++ src/lib/hooks/useHardcoverShelves.ts | 53 +++++++ 9 files changed, 459 insertions(+), 50 deletions(-) create mode 100644 public/goodreads-icon.png create mode 100644 public/hardcover-icon.svg create mode 100644 src/components/ui/ManageShelfModal.tsx diff --git a/public/goodreads-icon.png b/public/goodreads-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cbfa21c57e56f3016fea26494b3db5667d1ca70c GIT binary patch literal 1062 zcmV+>1ljwEP)vu=KRn7-*eBoQ1xf?=U+O+9HT?d zkpRCrK|YerskkK@l8O!P@r3P2B}ckGM=-!4GM>y`C@mc|(0Iaq-+j#bQwpaLe#@Fc>gw5ex?Ry(jVPrSZC* z_O?rG`v6Y_An4@WTS_l1me>Lyv#l-7Mu5nEdSPkb>$m%E(iL5R3{|QpadK{+63I7B;V*~b z^2(}SHouW7QUZ{D5d@FSmjl8^?%lPXhwndhwRg(F7631cl_naA{5N>}d(9Pst&$El z8oaT1Fe*R&$Z!WK{?oSI>#eVjVZKp;r6umy-2*O>klOE5NdO;o;>U<(jC#{5=+ z+~U;ip$MqJ768k{ni{R=bY{`j@4@>{2Vi^KMN&a(6ZiM^SfvxKv71^xp*MbF_Av)n zd<6hj!G3u44-6i-5||ax#G&^QKLc}sDoXRfklxE=7WM+i;DW58zel#$vpLFqSSl%- z*tlq=RptN_B?0jZmmMdta=X74%;00_8p&WL0_wLJb=G`05EWw3cg;w-xVhkJ{>)Gs;U?K5-$P3s45S*Q^>%HU_h=`qOq_}=ZbgDt^05jqD zLM{B;GZU-|PHu=EpPD8wI6lyKW0>;`$egCH!mr<(w7tDu@_Hx~GOS{LX_|Aly0-R1 z-%ADMvZ{2DApjEueh_9^#Q-n16p7gIK)DrLZ~GOC4D2$BlQ@~0H*OYo+Jv3^f7E*f diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts index ed072f1..7f5a226 100644 --- a/src/app/api/user/goodreads-shelves/[id]/route.ts +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; const logger = RMABLogger.create('API.GoodreadsShelves'); +const UpdateGoodreadsSchema = z.object({ + rssUrl: z.string().url('Must be a valid URL'), +}); + /** * DELETE /api/user/goodreads-shelves/[id] * Remove a Goodreads shelf subscription (ownership check) @@ -48,3 +54,57 @@ export async function DELETE( } }); } + +/** + * PATCH /api/user/goodreads-shelves/[id] + * Update a Goodreads shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'Shelf not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { rssUrl } = UpdateGoodreadsSchema.parse(body); + + // Force re-fetch by clearing metadata + const updated = await prisma.goodreadsShelf.update({ + where: { id }, + data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index 479b146..b7f94a5 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -7,9 +7,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; +import { z } from 'zod'; const logger = RMABLogger.create('API.HardcoverShelves'); +const UpdateHardcoverSchema = z.object({ + listId: z.string().min(1, 'List ID is required').optional(), + apiToken: z.string().optional(), +}); + /** * DELETE /api/user/hardcover-shelves/[id] * Remove a Hardcover shelf subscription (ownership check) @@ -53,3 +61,84 @@ export async function DELETE( } }); } + +/** + * PATCH /api/user/hardcover-shelves/[id] + * Update a Hardcover shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { listId, apiToken } = UpdateHardcoverSchema.parse(body); + + const updateData: any = {}; + let needsResync = false; + + if (listId && listId !== shelf.listId) { + updateData.listId = listId; + needsResync = true; + } + + if (apiToken && apiToken.trim() !== '') { + const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ') + ? apiToken.trim().slice(7).trim() + : apiToken.trim(); + const encryptionService = getEncryptionService(); + updateData.apiToken = encryptionService.encrypt(cleanedToken); + needsResync = true; + } + + // If we are forcing a resync due to a change, clear metadata + if (needsResync) { + updateData.lastSyncAt = null; + updateData.bookCount = null; + updateData.coverUrls = null; + } + + const updated = await prisma.hardcoverShelf.update({ + where: { id }, + data: updateData, + }); + + if (needsResync) { + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to update list' }, { status: 500 }); + } + }); +} diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index afe7879..b1bfb90 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -14,6 +14,7 @@ import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsM import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; import { Modal } from '@/components/ui/Modal'; +import { ManageShelfModal } from '@/components/ui/ManageShelfModal'; import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; function formatRelativeTime(dateStr: string | null): string { @@ -41,6 +42,7 @@ export function ShelvesSection() { const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [showAddShelf, setShowAddShelf] = useState(false); const [selectedAsin, setSelectedAsin] = useState(null); + const [manageShelf, setManageShelf] = useState(null); const handleDelete = async (shelf: GenericShelf) => { try { @@ -128,6 +130,7 @@ export function ShelvesSection() { onDelete={() => handleDelete(shelf)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} + onManage={() => setManageShelf(shelf)} onBookClick={(asin) => setSelectedAsin(asin)} /> ))} @@ -142,6 +145,12 @@ export function ShelvesSection() { onClose={() => setShowAddShelf(false)} /> + setManageShelf(null)} + shelf={manageShelf} + /> + {selectedAsin && ( void; onConfirmDelete: () => void; onCancelDelete: () => void; + onManage: () => void; onBookClick: (asin: string) => void; } @@ -255,6 +265,7 @@ function ShelfCard({ onDelete, onConfirmDelete, onCancelDelete, + onManage, onBookClick, }: ShelfCardProps) { const displayBooks = shelf.books.slice(0, 6); @@ -267,13 +278,17 @@ function ShelfCard({ const providerIcon = shelf.type === 'goodreads' ? ( - - g - + Goodreads ) : ( - - H - + Hardcover ); return ( @@ -336,25 +351,46 @@ function ShelfCard({
) : ( - + + + + + + )} diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx index de42246..a69fac4 100644 --- a/src/components/ui/AddShelfModal.tsx +++ b/src/components/ui/AddShelfModal.tsx @@ -161,19 +161,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { {provider === 'goodreads' ? ( <>
- - - + Goodreads

@@ -185,19 +177,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { ) : ( <>

- - - + Hardcover

diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx new file mode 100644 index 0000000..e46fa09 --- /dev/null +++ b/src/components/ui/ManageShelfModal.tsx @@ -0,0 +1,136 @@ +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { GenericShelf } from '@/lib/hooks/useShelves'; +import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { cn } from '@/lib/utils/cn'; + +interface ManageShelfModalProps { + shelf: GenericShelf | null; + isOpen: boolean; + onClose: () => void; +} + +export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { + const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : ''); + const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); + const [apiToken, setApiToken] = useState(''); + + const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf(); + + // Reset form when shelf changes + React.useEffect(() => { + if (shelf) { + setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : ''); + setListId(shelf.type === 'hardcover' ? shelf.sourceId : ''); + setApiToken(''); + } + }, [shelf]); + + if (!shelf) return null; + + const isUpdating = isUpdatingGoodreads || isUpdatingHardcover; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (shelf.type === 'goodreads') { + if (!rssUrl.trim()) return; + await updateGoodreads(shelf.id, rssUrl.trim()); + } else { + if (!listId.trim()) return; + await updateHardcover(shelf.id, { + listId: listId.trim(), + apiToken: apiToken.trim() || undefined, + }); + } + onClose(); + } catch (err) { + // Error is handled by hook + } + }; + + const isGoodreads = shelf.type === 'goodreads'; + + return ( + +

+
+ {isGoodreads ? ( +
+ + setRssUrl(e.target.value)} + placeholder="https://www.goodreads.com/review/list_rss/..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ ) : ( + <> +
+ + setListId(e.target.value)} + placeholder="e.g., 1234, want-to-read, status-1" + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+
+ + setApiToken(e.target.value)} + placeholder="Paste your Hardcover token here..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ + )} + +
+ + +
+
+
+ + ); +} diff --git a/src/lib/hooks/useGoodreadsShelves.ts b/src/lib/hooks/useGoodreadsShelves.ts index c803663..d67477b 100644 --- a/src/lib/hooks/useGoodreadsShelves.ts +++ b/src/lib/hooks/useGoodreadsShelves.ts @@ -125,3 +125,53 @@ export function useDeleteGoodreadsShelf() { return { deleteShelf, isLoading, error }; } + +export function useUpdateGoodreadsShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async (shelfId: string, rssUrl: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/goodreads-shelves/${shelfId}`, + { + method: 'PATCH', + 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 update shelf'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/goodreads-shelves'), + ); + mutate( + (key) => typeof key === 'string' && key.includes('/api/user/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 { updateShelf, isLoading, error }; +} diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts index 6dd24a6..f9a4bcc 100644 --- a/src/lib/hooks/useHardcoverShelves.ts +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -133,3 +133,56 @@ export function useDeleteHardcoverShelf() { return { deleteShelf, isLoading, error }; } + +export function useUpdateHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async ( + shelfId: string, + updates: { listId?: string; apiToken?: string }, + ) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/hardcover-shelves/${shelfId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to update list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-shelves'), + ); + mutate( + (key) => typeof key === 'string' && key.includes('/api/user/shelves'), + ); + + return data.shelf as HardcoverShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { updateShelf, isLoading, error }; +} From ae4a73144d396769bbf2046dad1cee8433d00212 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 13:20:28 -0700 Subject: [PATCH 12/19] cleanup dep jobs --- src/lib/services/scheduler.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index ccd6bed..a6f8436 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -175,10 +175,7 @@ export class SchedulerService { */ private async cleanupDeprecatedJobs(): Promise { try { - const deprecatedTypes = [ - 'sync_goodreads_shelves', - 'sync_hardcover_shelves', - ]; + const deprecatedTypes = ['sync_goodreads_shelves']; const obsoleteJobs = await prisma.scheduledJob.findMany({ where: { type: { in: deprecatedTypes } }, From ce8f4d642bd5302123873cd4c776e41cd1c1c81b Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 13:29:08 -0700 Subject: [PATCH 13/19] fix hardcover images --- src/components/profile/ShelvesSection.tsx | 2 +- src/components/ui/AddShelfModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index b1bfb90..7072270 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -285,7 +285,7 @@ function ShelfCard({ /> ) : ( Hardcover diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx index a69fac4..5e2a515 100644 --- a/src/components/ui/AddShelfModal.tsx +++ b/src/components/ui/AddShelfModal.tsx @@ -178,7 +178,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { <>
Hardcover From 6da2c4ce95715c81fad197e589d15bf7ee009b8c Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 13:39:52 -0700 Subject: [PATCH 14/19] Add tests --- tests/api/goodreads-shelves-id.routes.test.ts | 186 +++++++++++++++ tests/api/hardcover-shelves-id.routes.test.ts | 222 ++++++++++++++++++ tests/api/hardcover-shelves.routes.test.ts | 216 +++++++++++++++++ tests/helpers/prisma.ts | 1 + 4 files changed, 625 insertions(+) create mode 100644 tests/api/goodreads-shelves-id.routes.test.ts create mode 100644 tests/api/hardcover-shelves-id.routes.test.ts create mode 100644 tests/api/hardcover-shelves.routes.test.ts diff --git a/tests/api/goodreads-shelves-id.routes.test.ts b/tests/api/goodreads-shelves-id.routes.test.ts new file mode 100644 index 0000000..97bff25 --- /dev/null +++ b/tests/api/goodreads-shelves-id.routes.test.ts @@ -0,0 +1,186 @@ +/** + * Component: Goodreads Shelves [id] API Route Tests + * Documentation: documentation/backend/services/goodreads-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +const SHELF = { + id: 'shelf-1', + userId: 'user-1', + name: 'Want to Read', + rssUrl: 'https://www.goodreads.com/review/list_rss/12345', + lastSyncAt: null, + bookCount: 5, + coverUrls: null, + createdAt: new Date().toISOString(), +}; + +describe('DELETE /api/user/goodreads-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('deletes the shelf and returns success', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.delete).toHaveBeenCalledWith({ where: { id: 'shelf-1' } }); + }); + + it('returns 500 when deletion throws', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockRejectedValueOnce(new Error('db error')); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('Failed to delete shelf'); + }); +}); + +describe('PATCH /api/user/goodreads-shelves/[id]', () => { + const NEW_RSS = 'https://www.goodreads.com/review/list_rss/99999'; + + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns 400 for an invalid (non-URL) rssUrl', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: 'not-a-url' }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('updates the shelf, clears sync metadata, and triggers a sync job', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + const updatedShelf = { ...SHELF, rssUrl: NEW_RSS, lastSyncAt: null }; + prismaMock.goodreadsShelf.update.mockResolvedValueOnce(updatedShelf); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.update).toHaveBeenCalledWith({ + where: { id: 'shelf-1' }, + data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0); + }); + + it('still returns 200 even when the sync job fails to enqueue', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.update.mockResolvedValueOnce({ ...SHELF, rssUrl: NEW_RSS }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + // Sync job failure is swallowed; shelf update should still succeed + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); +}); diff --git a/tests/api/hardcover-shelves-id.routes.test.ts b/tests/api/hardcover-shelves-id.routes.test.ts new file mode 100644 index 0000000..338ce2d --- /dev/null +++ b/tests/api/hardcover-shelves-id.routes.test.ts @@ -0,0 +1,222 @@ +/** + * Component: Hardcover Shelves [id] API Route Tests + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((s: string) => `enc:${s}`), + decrypt: vi.fn((s: string) => s.replace('enc:', '')), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +const SHELF = { + id: 'hc-shelf-1', + userId: 'user-1', + name: 'Currently Reading', + listId: 'status-2', + apiToken: 'enc:secret-token', + lastSyncAt: null, + bookCount: 3, + coverUrls: null, + createdAt: new Date().toISOString(), +}; + +describe('DELETE /api/user/hardcover-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when list does not exist', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('List not found'); + }); + + it('returns 403 when list belongs to another user', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('deletes the list and returns success', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.hardcoverShelf.delete).toHaveBeenCalledWith({ where: { id: 'hc-shelf-1' } }); + }); + + it('returns 500 when deletion throws', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.delete.mockRejectedValueOnce(new Error('db error')); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('Failed to delete list'); + }); +}); + +describe('PATCH /api/user/hardcover-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when list does not exist', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('List not found'); + }); + + it('returns 403 when list belongs to another user', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('does not trigger a sync when no fields changed', async () => { + // listId is the same as existing; no apiToken provided + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: SHELF.listId }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled(); + }); + + it('updates listId, clears metadata, and triggers a sync job', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null }; + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(updated); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({ + where: { id: 'hc-shelf-1' }, + data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }), + }); + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0); + }); + + it('encrypts the apiToken before persisting', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + await PATCH( + { json: vi.fn().mockResolvedValue({ apiToken: 'my-raw-token' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + + expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token'); + expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({ + where: { id: 'hc-shelf-1' }, + data: expect.objectContaining({ apiToken: 'enc:my-raw-token' }), + }); + }); + + it('strips the Bearer prefix before encrypting the token', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + await PATCH( + { json: vi.fn().mockResolvedValue({ apiToken: 'Bearer my-raw-token' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + + expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token'); + }); + + it('still returns 200 even when the sync job fails to enqueue', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce({ ...SHELF, listId: 'status-3' }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); +}); diff --git a/tests/api/hardcover-shelves.routes.test.ts b/tests/api/hardcover-shelves.routes.test.ts new file mode 100644 index 0000000..fae8242 --- /dev/null +++ b/tests/api/hardcover-shelves.routes.test.ts @@ -0,0 +1,216 @@ +/** + * Component: Hardcover Shelves API Route Tests (POST / GET) + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((s: string) => `enc:${s}`), + decrypt: vi.fn((s: string) => s.replace('enc:', '')), +})); +const fetchHardcoverListMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/services/hardcover-sync.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + +const FETCHED_LIST = { + listName: 'Currently Reading', + books: [ + { title: 'Dune', author: 'Frank Herbert', coverUrl: 'https://example.com/dune.jpg' }, + { title: 'Foundation', author: 'Isaac Asimov', coverUrl: null }, + ], +}; + +describe('POST /api/user/hardcover-shelves', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ listId: 'status-2', apiToken: 'raw-token' }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 400 when apiToken is missing', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 400 when listId is missing', async () => { + authRequest.json.mockResolvedValueOnce({ apiToken: 'raw-token' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 409 when the list is already subscribed', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ id: 'existing-shelf' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('DuplicateShelf'); + }); + + it('returns 400 when Hardcover API fetch fails', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockRejectedValueOnce(new Error('Invalid token')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('InvalidHardcoverList'); + expect(payload.message).toContain('Invalid token'); + }); + + it('creates the shelf with an encrypted token and triggers sync', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(payload.shelf.name).toBe('Currently Reading'); + + // Token must have been encrypted before storage + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + apiToken: 'enc:raw-token', + listId: 'status-2', + userId: 'user-1', + }), + }) + ); + + // Immediate background sync must have been triggered + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0); + }); + + it('strips Bearer prefix from apiToken before encrypting', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2', apiToken: 'Bearer raw-token' }); + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-2', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + await POST({} as any); + + // "Bearer " prefix must have been stripped before encrypt was called + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + }); + + it('returns 201 even when the sync job fails to enqueue', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-3', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + }); + + it('only includes books with cover URLs in the initial shelf preview', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); // only 1 of 2 books has coverUrl + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-4', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + // The coverUrls stored should only include books with non-null coverUrl + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + // 1 book has cover, 1 doesn't → only 1 stored + coverUrls: JSON.stringify([ + { coverUrl: 'https://example.com/dune.jpg', asin: null, title: 'Dune', author: 'Frank Herbert' }, + ]), + }), + }) + ); + }); +}); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..6dfc5a1 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,7 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + hardcoverShelf: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); From 338331d00639b0d52aa3438dd10b4464abc6cbe1 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 10:11:19 -0500 Subject: [PATCH 15/19] Add Hardcover shelf sync & unify book mappings Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers. --- documentation/TABLEOFCONTENTS.md | 12 + .../backend/services/goodreads-sync.md | 75 +++ .../backend/services/hardcover-sync.md | 66 ++ .../migration.sql | 49 ++ .../migration.sql | 41 ++ prisma/schema.prisma | 53 +- .../api/user/hardcover-shelves/[id]/route.ts | 48 +- src/app/api/user/hardcover-shelves/route.ts | 25 +- src/app/api/user/shelves/route.ts | 28 +- src/components/profile/ShelvesSection.tsx | 6 +- src/components/ui/AddShelfModal.tsx | 226 ++----- src/components/ui/HardcoverForm.tsx | 318 +++++++++ src/components/ui/ManageShelfModal.tsx | 21 +- src/lib/hooks/createShelfHooks.ts | 172 +++++ src/lib/hooks/useGoodreadsShelves.ts | 150 +---- src/lib/hooks/useHardcoverShelves.ts | 158 +---- src/lib/services/goodreads-sync.service.ts | 370 +++-------- src/lib/services/hardcover-api.service.ts | 263 ++++++++ src/lib/services/hardcover-sync.service.ts | 610 ++---------------- src/lib/services/shelf-sync-core.service.ts | 274 ++++++++ src/lib/utils/shelf-helpers.ts | 36 ++ tests/helpers/prisma.ts | 2 +- tests/services/scheduler.service.test.ts | 1 - 23 files changed, 1613 insertions(+), 1391 deletions(-) create mode 100644 documentation/backend/services/goodreads-sync.md create mode 100644 documentation/backend/services/hardcover-sync.md create mode 100644 prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql create mode 100644 prisma/migrations/20260304000000_unify_book_mappings/migration.sql create mode 100644 src/components/ui/HardcoverForm.tsx create mode 100644 src/lib/hooks/createShelfHooks.ts create mode 100644 src/lib/services/hardcover-api.service.ts create mode 100644 src/lib/services/shelf-sync-core.service.ts create mode 100644 src/lib/utils/shelf-helpers.ts diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0d7fb76..7c6167d 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -32,6 +32,14 @@ - **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) - **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md) +## Reading Shelves (Goodreads, Hardcover) +- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md) +- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core) +- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory) +- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider) + ## Audible Integration - **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md) - **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md) @@ -150,3 +158,7 @@ **"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md) **"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) +**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md) +**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider) +**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core) diff --git a/documentation/backend/services/goodreads-sync.md b/documentation/backend/services/goodreads-sync.md new file mode 100644 index 0000000..ddfa9c0 --- /dev/null +++ b/documentation/backend/services/goodreads-sync.md @@ -0,0 +1,75 @@ +# Goodreads & Shelf Sync + +**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture + +## Overview +Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers. + +## Architecture + +### Files +- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core +- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation) +- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing +- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations +- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes +- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes +- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape) +- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory) + +### Database Models +- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`) +- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups. + +## Goodreads RSS Feed +- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}` +- **Auth:** None required (public RSS) +- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url` + +## Shared Sync Core + +`shelf-sync-core.service.ts` contains all provider-agnostic sync logic: + +### Interface: `ShelfBook` +```typescript +{ bookId: string; title: string; author: string; coverUrl?: string } +``` + +### Function: `processShelfBooks()` +Accepts provider-agnostic book list + context, performs: +1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`) +2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info) +3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days) +4. **Request creation** — Calls `createRequestForUser()` for matched ASINs +5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs +6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls` + +### Constants +- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers) +- `NO_MATCH_RETRY_DAYS` = 7 + +### Hook Factory: `createShelfHooks(endpoint)` +Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint. + +## API Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves | +| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) | +| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) | +| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) | +| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` | + +## Adding a New Provider +1. Create Prisma shelf model + migration (BookMapping table is already shared) +2. Create API client service for the external data source +3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()` +4. Create API routes (or use a generic route handler) +5. Create hook file (~40 lines) using `createShelfHooks(endpoint)` +6. Add tab in `AddShelfModal` with provider-specific form fields + +## Related +- [Hardcover sync](hardcover-sync.md) +- [Background jobs](jobs.md) +- [Scheduler](scheduler.md) diff --git a/documentation/backend/services/hardcover-sync.md b/documentation/backend/services/hardcover-sync.md new file mode 100644 index 0000000..e066c27 --- /dev/null +++ b/documentation/backend/services/hardcover-sync.md @@ -0,0 +1,66 @@ +# Hardcover Shelf Sync + +**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation + +## Overview +Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically. + +## Architecture + +### Files +- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()` +- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core +- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation) +- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes +- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes +- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory) + +### Database Models +- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`) +- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`) + +## Hardcover API + +- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based) +- **Auth:** Bearer token in Authorization header +- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables + +### Query Strategies (custom lists) +| Input | Strategy | Query root | +|---|---|---| +| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` | +| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` | +| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` | + +### Status Lists +- Prefix: `status-{id}` (e.g., `status-1`) +- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }` +- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish + +## Sync Flow +1. Fetch shelves from DB (all or specific `shelfId`) +2. Decrypt API token (encryption service) +3. Fetch books from Hardcover GraphQL API +4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment) +5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`) + +## API Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers | +| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) | +| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) | +| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) | + +## Key Details +- **Token cleanup:** Strips `Bearer ` prefix if user pastes it +- **Duplicate check:** Unique constraint on `(userId, listId)` +- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups +- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle) +- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display + +## Related +- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core) +- [Background jobs](jobs.md) +- [Scheduler](scheduler.md) diff --git a/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql b/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql new file mode 100644 index 0000000..15af2a9 --- /dev/null +++ b/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "hardcover_shelves" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "list_id" TEXT NOT NULL, + "api_token" TEXT NOT NULL, + "last_sync_at" TIMESTAMP(3), + "book_count" INTEGER, + "cover_urls" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "hardcover_book_mappings" ( + "id" TEXT NOT NULL, + "hardcover_book_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "audible_asin" TEXT, + "cover_url" TEXT, + "no_match" BOOLEAN NOT NULL DEFAULT false, + "last_search_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id"); + +-- CreateIndex +CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id"); + +-- CreateIndex +CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin"); + +-- AddForeignKey +ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260304000000_unify_book_mappings/migration.sql b/prisma/migrations/20260304000000_unify_book_mappings/migration.sql new file mode 100644 index 0000000..3dc99f9 --- /dev/null +++ b/prisma/migrations/20260304000000_unify_book_mappings/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "book_mappings" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "external_book_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "audible_asin" TEXT, + "cover_url" TEXT, + "no_match" BOOLEAN NOT NULL DEFAULT false, + "last_search_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id") +); + +-- Migrate data from goodreads_book_mappings +INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at") +SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at" +FROM "goodreads_book_mappings"; + +-- Migrate data from hardcover_book_mappings +INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at") +SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at" +FROM "hardcover_book_mappings"; + +-- DropTable +DROP TABLE "goodreads_book_mappings"; + +-- DropTable +DROP TABLE "hardcover_book_mappings"; + +-- CreateIndex +CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id"); + +-- CreateIndex +CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id"); + +-- CreateIndex +CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51f7634..a0cf3eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -518,26 +518,34 @@ model GoodreadsShelf { @@map("goodreads_shelves") } -model GoodreadsBookMapping { - id String @id @default(uuid()) - goodreadsBookId String @unique @map("goodreads_book_id") - title String - author String - audibleAsin String? @map("audible_asin") - coverUrl String? @map("cover_url") @db.Text - noMatch Boolean @default(false) @map("no_match") - lastSearchAt DateTime? @map("last_search_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") +// ============================================================================ +// UNIFIED BOOK MAPPING TABLE +// Global book-to-ASIN mapping cache shared across all shelf providers. +// Uses provider + externalBookId composite key for cross-provider dedup. +// ============================================================================ - @@index([goodreadsBookId]) +model BookMapping { + id String @id @default(uuid()) + provider String // "goodreads", "hardcover", etc. + externalBookId String @map("external_book_id") + title String + author String + audibleAsin String? @map("audible_asin") + coverUrl String? @map("cover_url") @db.Text + noMatch Boolean @default(false) @map("no_match") + lastSearchAt DateTime? @map("last_search_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([provider, externalBookId]) + @@index([provider, externalBookId]) @@index([audibleAsin]) - @@map("goodreads_book_mappings") + @@map("book_mappings") } // ============================================================================ // HARDCOVER SYNC TABLES -// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache +// Per-user Hardcover list subscriptions // ============================================================================ model HardcoverShelf { @@ -560,23 +568,6 @@ model HardcoverShelf { @@map("hardcover_shelves") } -model HardcoverBookMapping { - id String @id @default(uuid()) - hardcoverBookId String @unique @map("hardcover_book_id") // Internal ID from Hardcover - title String - author String - audibleAsin String? @map("audible_asin") - coverUrl String? @map("cover_url") @db.Text - noMatch Boolean @default(false) @map("no_match") - lastSearchAt DateTime? @map("last_search_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([hardcoverBookId]) - @@index([audibleAsin]) - @@map("hardcover_book_mappings") -} - // ============================================================================ // WORKS TABLE // Cross-ASIN audiobook identity mapping — links multiple Audible ASINs diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index b7f94a5..438cbcb 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -9,6 +9,7 @@ import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; +import { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; import { z } from 'zod'; const logger = RMABLogger.create('API.HardcoverShelves'); @@ -90,21 +91,50 @@ export async function PATCH( const body = await request.json(); const { listId, apiToken } = UpdateHardcoverSchema.parse(body); - const updateData: any = {}; + const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {}; let needsResync = false; - if (listId && listId !== shelf.listId) { - updateData.listId = listId; - needsResync = true; - } - + let cleanedToken: string | undefined; if (apiToken && apiToken.trim() !== '') { - const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ') + cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ') ? apiToken.trim().slice(7).trim() : apiToken.trim(); + } + + const newListId = (listId && listId !== shelf.listId) ? listId : undefined; + + // Validate token/listId by fetching the list before saving + if (cleanedToken || newListId) { const encryptionService = getEncryptionService(); - updateData.apiToken = encryptionService.encrypt(cleanedToken); - needsResync = true; + const tokenToTest = cleanedToken || (() => { + try { + return encryptionService.isEncryptedFormat(shelf.apiToken) + ? encryptionService.decrypt(shelf.apiToken) + : shelf.apiToken; + } catch { return shelf.apiToken; } + })(); + const listIdToTest = newListId || shelf.listId; + + try { + await fetchHardcoverList(tokenToTest, listIdToTest); + } catch (error) { + return NextResponse.json( + { + error: 'InvalidHardcoverList', + message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + if (newListId) { + updateData.listId = newListId; + needsResync = true; + } + if (cleanedToken) { + updateData.apiToken = encryptionService.encrypt(cleanedToken); + needsResync = true; + } } // If we are forcing a resync due to a change, clear metadata diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 4390220..725870b 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -11,6 +11,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; +import { processBooks } from '@/lib/utils/shelf-helpers'; const logger = RMABLogger.create('API.HardcoverShelves'); @@ -36,29 +37,7 @@ export async function GET(request: NextRequest) { }); const shelvesWithMeta = shelves.map((shelf) => { - let books: { - coverUrl: string; - asin: string | null; - title: string; - author: string; - }[] = []; - if (shelf.coverUrls) { - const parsed = JSON.parse(shelf.coverUrls); - if (Array.isArray(parsed)) { - books = parsed.map((item: unknown) => { - if (typeof item === 'string') { - return { coverUrl: item, asin: null, title: '', author: '' }; - } - const obj = item as Record; - return { - coverUrl: (obj.coverUrl as string) || '', - asin: (obj.asin as string) || null, - title: (obj.title as string) || '', - author: (obj.author as string) || '', - }; - }); - } - } + const books = processBooks(shelf.coverUrls); return { id: shelf.id, diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts index 93419df..f017a78 100644 --- a/src/app/api/user/shelves/route.ts +++ b/src/app/api/user/shelves/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { processBooks } from '@/lib/utils/shelf-helpers'; const logger = RMABLogger.create('API.Shelves'); @@ -32,33 +33,6 @@ export async function GET(request: NextRequest) { }), ]); - const processBooks = (coverUrls: string | null) => { - let books: { - coverUrl: string; - asin: string | null; - title: string; - author: string; - }[] = []; - if (coverUrls) { - const parsed = JSON.parse(coverUrls); - if (Array.isArray(parsed)) { - books = parsed.map((item: unknown) => { - if (typeof item === 'string') { - return { coverUrl: item, asin: null, title: '', author: '' }; - } - const obj = item as Record; - return { - coverUrl: (obj.coverUrl as string) || '', - asin: (obj.asin as string) || null, - title: (obj.title as string) || '', - author: (obj.author as string) || '', - }; - }); - } - } - return books; - }; - const combined = [ ...goodreads.map((s) => ({ id: s.id, diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index 7072270..5a9de8e 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -354,8 +354,9 @@ function ShelfCard({
@@ -147,97 +132,56 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' }`} - onClick={() => { - setProvider('hardcover'); - setValidationError(''); - }} + onClick={() => { setProvider('hardcover'); setValidationError(''); }} > Hardcover
- {/* Visual header */} + {/* Visual Header */}
{provider === 'goodreads' ? ( <>
- Goodreads -
-
-

- Paste your Goodreads shelf RSS URL. Books will be - automatically requested. -

+ Goodreads
+

+ Paste your Goodreads shelf RSS URL. Books will be automatically requested. +

) : ( <>
- Hardcover -
-
-

- Provide your Hardcover API token and select the list you want - to sync. -

+ Hardcover
+

+ Connect a Hardcover reading list and books will be automatically requested as you add them. +

)}
- {/* Success alert */} + {/* Success Alert */} {success && (
- - + +
-

- {successMessage} -

+

{successMessage}

)} - {/* Error alert */} + {/* Error Alert */} {currentError && (
- - + +
-

- {currentError} -

+

{currentError}

)} @@ -249,113 +193,37 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { type="url" label="Goodreads RSS URL" value={rssUrl} - onChange={(e) => { - setRssUrl(e.target.value); - if (validationError) setValidationError(''); - }} + onChange={(e) => { setRssUrl(e.target.value); if (validationError) setValidationError(''); }} placeholder="https://www.goodreads.com/review/list_rss/..." error={validationError} disabled={isLoading || success} />

- Find it on Goodreads: My Books → select a shelf → RSS - link at the bottom of the page. + Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.

) : ( -
- { - setApiToken(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="eyJhb..." - disabled={isLoading || success} - /> - -
- -
- - -
-
- - {listType === 'status' ? ( -
- -
- ) : ( - { - setCustomListId(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="https://hardcover.app/@username/lists/..." - error={validationError} - disabled={isLoading || success} - /> - )} -
+ )}
- -
diff --git a/src/components/ui/HardcoverForm.tsx b/src/components/ui/HardcoverForm.tsx new file mode 100644 index 0000000..ff531e1 --- /dev/null +++ b/src/components/ui/HardcoverForm.tsx @@ -0,0 +1,318 @@ +/** + * Component: Hardcover Shelf Form + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React from 'react'; +import { Input } from './Input'; + +// --------------------------------------------------------------------------- +// Status option definitions +// --------------------------------------------------------------------------- + +const STATUS_OPTIONS = [ + { + id: '1', + label: 'Want to Read', + description: 'Books saved to read later', + icon: ( + + + + ), + }, + { + id: '2', + label: 'Currently Reading', + description: 'Books actively being read', + icon: ( + + + + ), + }, + { + id: '3', + label: 'Read', + description: 'Books already finished', + icon: ( + + + + ), + }, + { + id: '4', + label: 'Did Not Finish', + description: 'Books started but set aside', + icon: ( + + + + ), + }, +] as const; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HardcoverFormProps { + apiToken: string; + setApiToken: (v: string) => void; + listType: 'status' | 'custom'; + setListType: (v: 'status' | 'custom') => void; + statusId: string; + setStatusId: (v: string) => void; + customListId: string; + setCustomListId: (v: string) => void; + validationError: string; + setValidationError: (v: string) => void; + isLoading: boolean; + success: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function HardcoverForm({ + apiToken, setApiToken, + listType, setListType, + statusId, setStatusId, + customListId, setCustomListId, + validationError, setValidationError, + isLoading, success, +}: HardcoverFormProps) { + const disabled = isLoading || success; + const isTokenError = validationError === 'Hardcover API Token is required'; + const isListError = !isTokenError && !!validationError; + + return ( +
+ + {/* API Token */} +
+
+ + + Get your token + + + + +
+ { + setApiToken(e.target.value); + if (isTokenError) setValidationError(''); + }} + placeholder="Paste your Hardcover API token" + disabled={disabled} + className={[ + 'block w-full rounded-lg border px-4 py-2 text-sm transition-colors', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60', + 'disabled:opacity-50 disabled:cursor-not-allowed', + 'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white', + 'placeholder-gray-400 dark:placeholder-gray-500', + isTokenError + ? 'border-red-400 dark:border-red-500' + : 'border-gray-200 dark:border-gray-700', + ].join(' ')} + /> + {isTokenError && ( +

{validationError}

+ )} +

+ Found under{' '} + Settings → API + {' '}on hardcover.app. Stored securely and never shared. +

+
+ + {/* Divider */} +
+ + {/* List Type Selection */} +
+
+

+ Which list should we watch? +

+

+ Choose a reading status or one of your custom lists. +

+
+ +
+ setListType('status')} + disabled={disabled} + icon={ + + + + } + title="Reading Status" + subtitle="Want to Read, Reading, Read, etc." + /> + setListType('custom')} + disabled={disabled} + icon={ + + + + } + title="Custom List" + subtitle="A list you created on Hardcover" + /> +
+
+ + {/* Status picker or Custom list input */} + {listType === 'status' ? ( +
+

Status to sync

+
+ {STATUS_OPTIONS.map((opt) => ( + setStatusId(opt.id)} + disabled={disabled} + /> + ))} +
+
+ ) : ( +
+ { + setCustomListId(e.target.value); + if (isListError) setValidationError(''); + }} + placeholder="https://hardcover.app/@username/lists/..." + error={isListError ? validationError : ''} + disabled={disabled} + /> +

+ Paste the list URL from Hardcover, or enter just the slug (e.g.{' '} + my-audiobooks + ) or a numeric ID. +

+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function ListTypeCard({ + active, onClick, disabled, icon, title, subtitle, +}: { + active: boolean; + onClick: () => void; + disabled: boolean; + icon: React.ReactNode; + title: string; + subtitle: string; +}) { + return ( + + ); +} + +function StatusRow({ + opt, selected, onSelect, disabled, +}: { + opt: typeof STATUS_OPTIONS[number]; + selected: boolean; + onSelect: () => void; + disabled: boolean; +}) { + return ( + + ); +} diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx index e46fa09..7dce0a6 100644 --- a/src/components/ui/ManageShelfModal.tsx +++ b/src/components/ui/ManageShelfModal.tsx @@ -1,3 +1,8 @@ +/** + * Component: Manage Shelf Modal + * Documentation: documentation/frontend/components.md + */ + 'use client'; import React, { useState } from 'react'; @@ -18,8 +23,8 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); const [apiToken, setApiToken] = useState(''); - const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf(); - const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf(); + const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf(); // Reset form when shelf changes React.useEffect(() => { @@ -33,6 +38,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro if (!shelf) return null; const isUpdating = isUpdatingGoodreads || isUpdatingHardcover; + const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -58,6 +64,17 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro return (
+ {currentError && ( +
+
+ + + +
+

{currentError}

+
+ )} +
{isGoodreads ? (
diff --git a/src/lib/hooks/createShelfHooks.ts b/src/lib/hooks/createShelfHooks.ts new file mode 100644 index 0000000..d66643e --- /dev/null +++ b/src/lib/hooks/createShelfHooks.ts @@ -0,0 +1,172 @@ +/** + * Component: Shelf Hook Factory + * Documentation: documentation/frontend/components.md + * + * Generic hook factory for shelf CRUD operations. Each provider (Goodreads, + * Hardcover, etc.) calls this with its API endpoint to get fully typed hooks + * without duplicating the SWR/fetch/mutate boilerplate. + */ + +'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; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +/** + * Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint. + */ +function revalidate(endpoint: string) { + mutate((key) => typeof key === 'string' && key.includes(endpoint)); + mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves')); +} + +/** + * Creates a set of hooks for a shelf provider endpoint. + * + * Returns: + * - useList: SWR-based hook to list shelves + * - useAdd: Hook returning { addShelf(body), isLoading, error } + * - useDelete: Hook returning { deleteShelf(id), isLoading, error } + * - useUpdate: Hook returning { updateShelf(id, body), isLoading, error } + */ +export function createShelfHooks(endpoint: string) { + function useList() { + const { accessToken } = useAuth(); + const key = accessToken ? endpoint : null; + + const { data, error, isLoading } = useSWR(key, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as TShelf[], + isLoading, + error, + }; + } + + function useAdd() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addShelf = async (body: Record) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to add shelf'); + } + + revalidate(endpoint); + return data.shelf as TShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addShelf, isLoading, error }; + } + + function useDelete() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteShelf = async (shelfId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`${endpoint}/${shelfId}`, { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to remove shelf'); + } + + revalidate(endpoint); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { deleteShelf, isLoading, error }; + } + + function useUpdate() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async (shelfId: string, body: Record) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`${endpoint}/${shelfId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to update shelf'); + } + + revalidate(endpoint); + return data.shelf as TShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { updateShelf, isLoading, error }; + } + + return { useList, useAdd, useDelete, useUpdate }; +} diff --git a/src/lib/hooks/useGoodreadsShelves.ts b/src/lib/hooks/useGoodreadsShelves.ts index d67477b..4b98728 100644 --- a/src/lib/hooks/useGoodreadsShelves.ts +++ b/src/lib/hooks/useGoodreadsShelves.ts @@ -5,17 +5,9 @@ 'use client'; -import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; -import { useAuth } from '@/contexts/AuthContext'; -import { fetchWithAuth } from '@/lib/utils/api'; +import { createShelfHooks, ShelfBook } from './createShelfHooks'; -export interface ShelfBook { - coverUrl: string; - asin: string | null; - title: string; - author: string; -} +export type { ShelfBook }; export interface GoodreadsShelf { id: string; @@ -27,150 +19,28 @@ export interface GoodreadsShelf { books: ShelfBook[]; } -const fetcher = (url: string) => - fetchWithAuth(url).then((res) => res.json()); +const { useList, useAdd, useDelete, useUpdate } = + createShelfHooks('/api/user/goodreads-shelves'); -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 const useGoodreadsShelves = useList; export function useAddGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { addShelf: addGeneric, isLoading, error } = useAdd(); 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 addGeneric({ rssUrl }); }; return { addShelf, isLoading, error }; } -export function useDeleteGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(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 }; -} +export const useDeleteGoodreadsShelf = useDelete; export function useUpdateGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); const updateShelf = async (shelfId: string, rssUrl: string) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth( - `/api/user/goodreads-shelves/${shelfId}`, - { - method: 'PATCH', - 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 update shelf'); - } - - // Revalidate shelves list - mutate( - (key) => - typeof key === 'string' && - key.includes('/api/user/goodreads-shelves'), - ); - mutate( - (key) => typeof key === 'string' && key.includes('/api/user/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 updateGeneric(shelfId, { rssUrl }); }; return { updateShelf, isLoading, error }; diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts index f9a4bcc..b845917 100644 --- a/src/lib/hooks/useHardcoverShelves.ts +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -5,17 +5,9 @@ 'use client'; -import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; -import { useAuth } from '@/contexts/AuthContext'; -import { fetchWithAuth } from '@/lib/utils/api'; +import { createShelfHooks, ShelfBook } from './createShelfHooks'; -export interface ShelfBook { - coverUrl: string; - asin: string | null; - title: string; - author: string; -} +export type { ShelfBook }; export interface HardcoverShelf { id: string; @@ -27,161 +19,31 @@ export interface HardcoverShelf { books: ShelfBook[]; } -const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); +const { useList, useAdd, useDelete, useUpdate } = + createShelfHooks('/api/user/hardcover-shelves'); -export function useHardcoverShelves() { - const { accessToken } = useAuth(); - - const endpoint = accessToken ? '/api/user/hardcover-shelves' : null; - - const { data, error, isLoading } = useSWR(endpoint, fetcher, { - refreshInterval: 30000, - }); - - return { - shelves: (data?.shelves || []) as HardcoverShelf[], - isLoading, - error, - }; -} +export const useHardcoverShelves = useList; export function useAddHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { addShelf: addGeneric, isLoading, error } = useAdd(); const addShelf = async (apiToken: string, listId: string) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth('/api/user/hardcover-shelves', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiToken, listId }), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || 'Failed to add list'); - } - - // Revalidate shelves list - mutate( - (key) => - typeof key === 'string' && - key.includes('/api/user/hardcover-shelves'), - ); - - return data.shelf as HardcoverShelf; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - setError(message); - throw err; - } finally { - setIsLoading(false); - } + return addGeneric({ apiToken, listId }); }; return { addShelf, isLoading, error }; } -export function useDeleteHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const deleteShelf = async (shelfId: string) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth( - `/api/user/hardcover-shelves/${shelfId}`, - { - method: 'DELETE', - }, - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || 'Failed to remove list'); - } - - // Revalidate shelves list - mutate( - (key) => - typeof key === 'string' && - key.includes('/api/user/hardcover-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 }; -} +export const useDeleteHardcoverShelf = useDelete; export function useUpdateHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); const updateShelf = async ( shelfId: string, updates: { listId?: string; apiToken?: string }, ) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth( - `/api/user/hardcover-shelves/${shelfId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }, - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || 'Failed to update list'); - } - - // Revalidate shelves list - mutate( - (key) => - typeof key === 'string' && - key.includes('/api/user/hardcover-shelves'), - ); - mutate( - (key) => typeof key === 'string' && key.includes('/api/user/shelves'), - ); - - return data.shelf as HardcoverShelf; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - setError(message); - throw err; - } finally { - setIsLoading(false); - } + return updateGeneric(shelfId, updates); }; return { updateShelf, isLoading, error }; diff --git a/src/lib/services/goodreads-sync.service.ts b/src/lib/services/goodreads-sync.service.ts index 8653435..37dc668 100644 --- a/src/lib/services/goodreads-sync.service.ts +++ b/src/lib/services/goodreads-sync.service.ts @@ -2,36 +2,29 @@ * Component: Goodreads Shelf Sync Service * Documentation: documentation/backend/services/goodreads-sync.md * - * Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs, - * and creates requests via the shared request-creator service. + * Fetches Goodreads shelf RSS feeds and delegates book processing + * to the shared shelf-sync-core service. */ import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; import { prisma } from '@/lib/db'; -import { getAudibleService } from '@/lib/integrations/audible.service'; -import { createRequestForUser } from '@/lib/services/request-creator.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { + ShelfBook, + ShelfSyncStats, + ShelfSyncOptions, + createEmptyStats, + resolveMaxLookups, + processShelfBooks, +} from '@/lib/services/shelf-sync-core.service'; const logger = RMABLogger.create('GoodreadsSync'); -/** Default max Audible lookups per shelf per scheduled sync cycle */ -const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10; - -/** Days before retrying a noMatch book */ -const NO_MATCH_RETRY_DAYS = 7; - -interface GoodreadsRssBook { - bookId: string; - title: string; - author: string; - coverUrl?: string; -} - /** * Parse a Goodreads RSS feed XML into structured book data. */ -function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } { +function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', @@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf'; - // Normalize items to array let items = channel.item; if (!items) return { shelfName, books: [] }; if (!Array.isArray(items)) items = [items]; - const books: GoodreadsRssBook[] = []; + const books: ShelfBook[] = []; for (const item of items) { const bookId = item.book_id?.toString(); if (!bookId) continue; const title = (item.title || '').toString().trim(); - const authorName = (item.author_name || '').toString().trim(); - // Goodreads RSS has book_image_url or book_medium_image_url + const author = (item.author_name || '').toString().trim(); const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined; - if (title && authorName) { - books.push({ bookId, title, author: authorName, coverUrl }); + if (title && author) { + books.push({ bookId, title, author, coverUrl }); } } return { shelfName, books }; } +/** Max items Goodreads returns per RSS page */ +const GOODREADS_PAGE_SIZE = 100; + +/** Safety cap to avoid infinite loops */ +const MAX_PAGES = 50; + /** * Fetch and validate a Goodreads RSS URL. - * Returns the parsed shelf name and books if valid. + * Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items. + * Deduplicates by bookId across pages. */ -export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> { - const response = await axios.get(rssUrl, { timeout: 15000 }); - return parseGoodreadsRss(response.data); +export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> { + const url = new URL(rssUrl); + url.searchParams.set('sort', 'title'); + + let shelfName = 'Goodreads Shelf'; + const seenIds = new Set(); + const allBooks: ShelfBook[] = []; + + for (let page = 1; page <= MAX_PAGES; page++) { + url.searchParams.set('page', page.toString()); + + const response = await axios.get(url.toString(), { timeout: 15000 }); + const parsed = parseGoodreadsRss(response.data); + + if (page === 1) { + shelfName = parsed.shelfName; + } + + for (const book of parsed.books) { + if (!seenIds.has(book.bookId)) { + seenIds.add(book.bookId); + allBooks.push(book); + } + } + + if (parsed.books.length < GOODREADS_PAGE_SIZE) break; + } + + return { shelfName, books: allBooks }; } -export interface GoodreadsSyncStats { - shelvesProcessed: number; - booksFound: number; - lookupsPerformed: number; - requestsCreated: number; - errors: number; -} - -export interface GoodreadsSyncOptions { - /** Process only this shelf ID (for immediate single-shelf sync) */ - shelfId?: string; - /** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */ - maxLookupsPerShelf?: number; -} +// Re-export types that downstream consumers expect +export type { ShelfSyncStats as GoodreadsSyncStats }; +export type { ShelfSyncOptions as GoodreadsSyncOptions }; /** * Process Goodreads shelves: fetch RSS, resolve ASINs, create requests. - * Called from the dedicated sync_goodreads_shelves processor. + * Called from the unified sync_reading_shelves processor. */ export async function processGoodreadsShelves( jobLogger?: ReturnType, - options: GoodreadsSyncOptions = {} -): Promise { + options: ShelfSyncOptions = {} +): Promise { const log = jobLogger || logger; - const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 }; - - const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; + const stats = createEmptyStats(); + const maxLookups = resolveMaxLookups(options); const whereClause = options.shelfId ? { id: options.shelfId } : {}; const shelves = await prisma.goodreadsShelf.findMany({ @@ -121,7 +133,32 @@ export async function processGoodreadsShelves( for (const shelf of shelves) { try { - await processShelf(shelf, stats, log, maxLookups); + log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`); + + let rssData: { shelfName: string; books: ShelfBook[] }; + try { + rssData = await fetchAndValidateRss(shelf.rssUrl); + } catch (error) { + log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`); + stats.errors++; + continue; + } + + log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`); + + const bookData = await processShelfBooks( + 'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups, + ); + + await prisma.goodreadsShelf.update({ + where: { id: shelf.id }, + data: { + lastSyncAt: new Date(), + bookCount: rssData.books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); + stats.shelvesProcessed++; } catch (error) { stats.errors++; @@ -132,238 +169,3 @@ export async function processGoodreadsShelves( log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`); return stats; } - -async function processShelf( - shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } }, - stats: GoodreadsSyncStats, - log: ReturnType | ReturnType, - maxLookups: number -) { - log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`); - - let rssData: { shelfName: string; books: GoodreadsRssBook[] }; - try { - rssData = await fetchAndValidateRss(shelf.rssUrl); - } catch (error) { - log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`); - return; - } - - const books = rssData.books; - stats.booksFound += books.length; - log.info(`Found ${books.length} books in shelf "${shelf.name}"`); - - let lookupsThisCycle = 0; - const unlimitedLookups = maxLookups === 0; - - for (const book of books) { - // Look up existing mapping - let mapping = await prisma.goodreadsBookMapping.findUnique({ - where: { goodreadsBookId: book.bookId }, - }); - - if (!mapping) { - // No mapping exists — perform Audible lookup if under cap - if (!unlimitedLookups && lookupsThisCycle >= maxLookups) { - continue; // Will be resolved in a future cycle - } - - mapping = await performAudibleLookup(book, log); - lookupsThisCycle++; - stats.lookupsPerformed++; - - // If lookup found an ASIN, fall through to create request immediately - if (!mapping?.audibleAsin) { - continue; - } - } - - // Mapping exists with noMatch — check if we should retry - if (mapping.noMatch) { - if (mapping.lastSearchAt) { - const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24); - if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) { - log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`); - mapping = await performAudibleLookup(book, log, mapping.id); - lookupsThisCycle++; - stats.lookupsPerformed++; - - // If retry found an ASIN, fall through to create request - if (!mapping?.audibleAsin) { - continue; - } - } else { - continue; // Still no match, skip - } - } else { - continue; - } - } - - // Mapping has ASIN — try to create request - if (mapping.audibleAsin) { - try { - const result = await createRequestForUser(shelf.user.id, { - asin: mapping.audibleAsin, - title: mapping.title, - author: mapping.author, - coverArtUrl: mapping.coverUrl || undefined, - }); - - if (result.success) { - stats.requestsCreated++; - log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`); - } - // If not success, it's already available/requested/duplicate — silently skip - } catch (error) { - log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - } - - // Collect enriched book data (coverUrl + ASIN) for display - const bookIds = books.map(b => b.bookId); - const mappings = bookIds.length > 0 - ? await prisma.goodreadsBookMapping.findMany({ - where: { goodreadsBookId: { in: bookIds } }, - select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true }, - }) - : []; - const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m])); - - // Look up AudibleCache records for high-quality cached cover URLs - const matchedAsins = mappings - .map(m => m.audibleAsin) - .filter((asin): asin is string => !!asin); - const cachedCovers = matchedAsins.length > 0 - ? await prisma.audibleCache.findMany({ - where: { asin: { in: matchedAsins } }, - select: { asin: true, coverArtUrl: true, cachedCoverPath: true }, - }) - : []; - const coverByAsin = new Map( - cachedCovers - .filter(c => c.cachedCoverPath || c.coverArtUrl) - .map(c => { - let coverUrl = c.coverArtUrl || ''; - if (c.cachedCoverPath) { - const filename = c.cachedCoverPath.split('/').pop(); - coverUrl = `/api/cache/thumbnails/${filename}`; - } - return [c.asin, coverUrl] as const; - }) - ); - - const bookData = books - .map(b => { - const mapping = mappingsByBookId.get(b.bookId); - // Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover - const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl; - if (!coverUrl) return null; - return { - coverUrl, - asin: mapping?.audibleAsin || null, - title: mapping?.title || b.title, - author: mapping?.author || b.author, - }; - }) - .filter((b): b is NonNullable => b !== null) - .slice(0, 8); - - // Update shelf metadata - await prisma.goodreadsShelf.update({ - where: { id: shelf.id }, - data: { - lastSyncAt: new Date(), - bookCount: books.length, - coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, - }, - }); -} - -async function performAudibleLookup( - book: GoodreadsRssBook, - log: ReturnType | ReturnType, - existingMappingId?: string -): Promise { - const audibleService = getAudibleService(); - - try { - // Try full Goodreads title first, then fall back to stripped title - // (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results) - const fullQuery = `${book.title} ${book.author}`; - log.info(`Searching Audible for: "${fullQuery}"`); - - let searchResult = await audibleService.search(fullQuery); - let firstResult = searchResult.results[0]; - - if (!firstResult?.asin) { - const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim(); - if (cleanTitle !== book.title) { - const cleanQuery = `${cleanTitle} ${book.author}`; - log.info(`No results with full title, retrying without series info: "${cleanQuery}"`); - searchResult = await audibleService.search(cleanQuery); - firstResult = searchResult.results[0]; - } - } - - if (firstResult?.asin) { - log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`); - - // Use clean Audible/Audnexus metadata instead of Goodreads data - // (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches) - const data = { - title: firstResult.title, - author: firstResult.author, - audibleAsin: firstResult.asin, - coverUrl: firstResult.coverArtUrl || book.coverUrl || null, - noMatch: false, - lastSearchAt: new Date(), - }; - - if (existingMappingId) { - return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...data }, - }); - } - - // No match found - log.info(`No Audible match for "${book.title}" by ${book.author}`); - - const noMatchData = { - title: book.title, - author: book.author, - coverUrl: book.coverUrl || null, - noMatch: true, - lastSearchAt: new Date(), - audibleAsin: null, - }; - - if (existingMappingId) { - return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...noMatchData }, - }); - } catch (error) { - log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); - - // Still create/update mapping so we don't retry every cycle - const errorData = { - title: book.title, - author: book.author, - coverUrl: book.coverUrl || null, - noMatch: true, - lastSearchAt: new Date(), - }; - - if (existingMappingId) { - return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...errorData }, - }); - } -} diff --git a/src/lib/services/hardcover-api.service.ts b/src/lib/services/hardcover-api.service.ts new file mode 100644 index 0000000..d8e4df8 --- /dev/null +++ b/src/lib/services/hardcover-api.service.ts @@ -0,0 +1,263 @@ +/** + * Component: Hardcover API Service + * Documentation: documentation/backend/services/hardcover-sync.md + * + * GraphQL queries and API communication with the Hardcover platform. + * Exports fetchHardcoverList for use by the sync orchestration layer. + */ + +import axios from 'axios'; + +const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; + +export interface HardcoverApiBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** + * Fetch a Hardcover List using their GraphQL API. + * This handles both 'status_id' user_books or 'list_id' list_books queries. + * For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id. + */ +export async function fetchHardcoverList( + apiToken: string, + listIdStr: string, +): Promise<{ listName: string; books: HardcoverApiBook[] }> { + // Check if it's a status list + const isStatus = listIdStr.startsWith('status-'); + + if (isStatus) { + const statusId = parseInt(listIdStr.replace('status-', ''), 10); + const query = ` + query GetStatusBooks($statusId: Int!) { + me { + user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) { + book { + id + title + contributions { + author { + name + } + } + cached_image + image { + url + } + } + } + } + } + `; + + const response = await axios.post( + HARDCOVER_API_URL, + { query, variables: { statusId } }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); + + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); + } + + const userBooks = response.data?.data?.me?.[0]?.user_books || []; + let listName = 'Hardcover Status List'; + + // Map status numbers to names + const statusNames: Record = { + 1: 'Want to Read', + 2: 'Currently Reading', + 3: 'Read', + 4: 'Did Not Finish', + }; + listName = statusNames[statusId] || `Status ${statusId}`; + + const books: HardcoverApiBook[] = []; + for (const item of userBooks) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; + } else { + // Custom list query + // - URL with @username → query that user's lists by slug + // - Bare slug (no username) → query authenticated user's lists via `me` + // - Numeric ID → query globally (IDs are unique) + const isIntId = /^\d+$/.test(listIdStr); + let extractedSlug = listIdStr; + let extractedUsername: string | null = null; + + if (!isIntId) { + try { + if (listIdStr.includes('hardcover.app')) { + const url = new URL( + listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`, + ); + const parts = url.pathname.split('/').filter(Boolean); + // URL format: /@username/lists/slug + if (parts.length > 0) { + extractedSlug = parts[parts.length - 1]; + } + const userPart = parts.find((p) => p.startsWith('@')); + if (userPart) { + extractedUsername = userPart.slice(1); + } + } + } catch (e) { + // use extractedSlug as-is + } + } + + const listBookFields = ` + name + list_books(limit: 100, order_by: {id: desc}) { + book { + id title cached_image image { url } + contributions { author { name } } + } + } + `; + + // Numeric ID: globally unique, query the lists table directly + const queryById = ` + query GetListBooks($listId: Int!) { + lists(where: {id: {_eq: $listId}}, limit: 1) { + ${listBookFields} + } + } + `; + + // Slug with username: query through the users table to scope to that user + const queryByUserSlug = ` + query GetUserListBySlug($username: citext!, $slug: String!) { + users(where: {username: {_eq: $username}}, limit: 1) { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + ${listBookFields} + } + } + } + `; + + // Bare slug (no username): scope to the authenticated user via `me` + const queryByMySlug = ` + query GetMyListBySlug($slug: String!) { + me { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + ${listBookFields} + } + } + } + `; + + let activeQuery: string; + let variables: Record; + + if (isIntId) { + activeQuery = queryById; + variables = { listId: parseInt(listIdStr, 10) }; + } else if (extractedUsername) { + activeQuery = queryByUserSlug; + variables = { username: extractedUsername, slug: extractedSlug }; + } else { + activeQuery = queryByMySlug; + variables = { slug: extractedSlug }; + } + + const response = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); + + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); + } + + // Extract lists array from the response based on which query was used + let listsData: any[]; + if (isIntId) { + listsData = response.data?.data?.lists || []; + } else if (extractedUsername) { + const users = response.data?.data?.users || []; + listsData = users[0]?.lists || []; + } else { + listsData = response.data?.data?.me?.[0]?.lists || []; + } + + if (listsData.length === 0) { + let identifier: string; + if (isIntId) { + identifier = `ID "${listIdStr}"`; + } else if (extractedUsername) { + identifier = `slug "${extractedSlug}" for user @${extractedUsername}`; + } else { + identifier = `slug "${extractedSlug}" in your Hardcover account`; + } + throw new Error(`Could not find a list with ${identifier}`); + } + + const listName = listsData[0].name || 'Hardcover List'; + const listBooks = listsData[0].list_books || []; + + const books: HardcoverApiBook[] = []; + for (const item of listBooks) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; + } +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index e644d14..edad091 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -2,279 +2,42 @@ * Component: Hardcover Shelf Sync Service * Documentation: documentation/backend/services/hardcover-sync.md * - * Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs, - * and creates requests via the shared request-creator service. + * Fetches Hardcover lists via GraphQL API and delegates book processing + * to the shared shelf-sync-core service. */ -import axios from 'axios'; import { prisma } from '@/lib/db'; -import { getAudibleService } from '@/lib/integrations/audible.service'; -import { createRequestForUser } from '@/lib/services/request-creator.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service'; +import { + ShelfSyncStats, + ShelfSyncOptions, + createEmptyStats, + resolveMaxLookups, + processShelfBooks, +} from '@/lib/services/shelf-sync-core.service'; + +export { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; +export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service'; const logger = RMABLogger.create('HardcoverSync'); -/** Default max Audible lookups per shelf per scheduled sync cycle */ -const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10; - -/** Days before retrying a noMatch book */ -const NO_MATCH_RETRY_DAYS = 7; - -const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; - -interface HardcoverApiBook { - bookId: string; - title: string; - author: string; - coverUrl?: string; -} +// Re-export types that downstream consumers expect +export type { ShelfSyncStats as HardcoverSyncStats }; +export type { ShelfSyncOptions as HardcoverSyncOptions }; /** - * Fetch a Hardcover List using their GraphQL API. - * This handles both 'status_id' user_books or 'list_id' list_books queries. - * For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id. + * Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests. + * Called from the unified sync_reading_shelves processor. */ -export async function fetchHardcoverList( - apiToken: string, - listIdStr: string, -): Promise<{ listName: string; books: HardcoverApiBook[] }> { - // Check if it's a status list - const isStatus = listIdStr.startsWith('status-'); - - if (isStatus) { - const statusId = parseInt(listIdStr.replace('status-', ''), 10); - const query = ` - query GetStatusBooks($statusId: Int!) { - me { - user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) { - book { - id - title - contributions { - author { - name - } - } - cached_image - image { - url - } - } - } - } - } - `; - - const response = await axios.post( - HARDCOVER_API_URL, - { query, variables: { statusId } }, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - timeout: 30000, - }, - ); - - if (response.data?.errors) { - throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, - ); - } - - const userBooks = response.data?.data?.me?.[0]?.user_books || []; - let listName = 'Hardcover Status List'; - - // Map status numbers to names - const statusNames: Record = { - 1: 'Want to Read', - 2: 'Currently Reading', - 3: 'Read', - 4: 'Did Not Finish', - }; - listName = statusNames[statusId] || `Status ${statusId}`; - - const books: HardcoverApiBook[] = []; - for (const item of userBooks) { - const book = item.book; - if (!book || !book.id) continue; - - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const coverUrl = book.cached_image || book.image?.url || undefined; - - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); - } - - return { listName, books }; - } else { - // Original list_books logic - let isUuid = false; - let isIntId = false; - let extractedSlug = listIdStr; - - if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - listIdStr, - ) - ) { - isUuid = true; - } else if (/^\d+$/.test(listIdStr)) { - isIntId = true; - } else { - try { - if (listIdStr.includes('hardcover.app')) { - const url = new URL( - listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`, - ); - const parts = url.pathname.split('/').filter(Boolean); - if (parts.length > 0) { - extractedSlug = parts[parts.length - 1]; - } - } - } catch (e) { - // use extractedSlug as-is - } - } - - const query = ` - query GetListBooks($listId: Int!) { - list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) { - list { name } - book { - id title cached_image image { url } - contributions { author { name } } - } - } - } - `; - - const queryUuid = ` - query GetListBooksUuid($listId: uuid!) { - list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) { - list { name } - book { - id title cached_image image { url } - contributions { author { name } } - } - } - } - `; - - const querySlug = ` - query GetListBooksBySlug($slug: String!) { - lists(where: {slug: {_eq: $slug}}, limit: 1) { - name - list_books(limit: 100, order_by: {id: desc}) { - book { - id title cached_image image { url } - contributions { author { name } } - } - } - } - } - `; - - const isSlug = !isUuid && !isIntId; - const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query; - const variables = isSlug - ? { slug: extractedSlug } - : { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) }; - - const response = await axios.post( - HARDCOVER_API_URL, - { - query: activeQuery, - variables, - }, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - timeout: 30000, - }, - ); - - if (response.data?.errors) { - throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, - ); - } - - let listName = 'Hardcover List'; - let listBooks: any[] = []; - - if (isSlug) { - const listsData = response.data?.data?.lists || []; - if (listsData.length === 0) { - throw new Error(`Could not find a list with slug "${extractedSlug}"`); - } - listName = listsData[0].name || listName; - listBooks = listsData[0].list_books || []; - } else { - listBooks = response.data?.data?.list_books || []; - if (listBooks.length > 0 && listBooks[0].list?.name) { - listName = listBooks[0].list.name; - } - } - - const books: HardcoverApiBook[] = []; - for (const item of listBooks) { - const book = item.book; - if (!book || !book.id) continue; - - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const coverUrl = book.cached_image || book.image?.url || undefined; - - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); - } - - return { listName, books }; - } -} - -export interface HardcoverSyncStats { - shelvesProcessed: number; - booksFound: number; - lookupsPerformed: number; - requestsCreated: number; - errors: number; -} - -export interface HardcoverSyncOptions { - shelfId?: string; - maxLookupsPerShelf?: number; -} - export async function processHardcoverShelves( jobLogger?: ReturnType, - options: HardcoverSyncOptions = {}, -): Promise { + options: ShelfSyncOptions = {}, +): Promise { const log = jobLogger || logger; - const stats: HardcoverSyncStats = { - shelvesProcessed: 0, - booksFound: 0, - lookupsPerformed: 0, - requestsCreated: 0, - errors: 0, - }; - - const maxLookups = - options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; + const stats = createEmptyStats(); + const maxLookups = resolveMaxLookups(options); const whereClause = options.shelfId ? { id: options.shelfId } : {}; const shelves = await prisma.hardcoverShelf.findMany({ @@ -297,7 +60,50 @@ export async function processHardcoverShelves( for (const shelf of shelves) { try { - await processShelf(shelf, stats, log, maxLookups); + log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`); + + const encryptionService = getEncryptionService(); + let decryptedToken = shelf.apiToken; + try { + if (encryptionService.isEncryptedFormat(shelf.apiToken)) { + decryptedToken = encryptionService.decrypt(shelf.apiToken); + } + } catch (err) { + log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`); + } + + let fetchedData: { listName: string; books: HardcoverApiBook[] }; + try { + fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId); + } catch (error) { + log.error( + `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + stats.errors++; + continue; + } + + log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`); + + const bookData = await processShelfBooks( + 'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups, + ); + + const finalListName = + fetchedData.listName !== 'Hardcover List' + ? fetchedData.listName + : shelf.name; + + await prisma.hardcoverShelf.update({ + where: { id: shelf.id }, + data: { + name: finalListName, + lastSyncAt: new Date(), + bookCount: fetchedData.books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); + stats.shelvesProcessed++; } catch (error) { stats.errors++; @@ -312,287 +118,3 @@ export async function processHardcoverShelves( ); return stats; } - -async function processShelf( - shelf: { - id: string; - listId: string; - apiToken: string; - name: string; - user: { id: string; plexUsername: string }; - }, - stats: HardcoverSyncStats, - log: - | ReturnType - | ReturnType, - maxLookups: number, -) { - log.info( - `Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`, - ); - - const encryptionService = getEncryptionService(); - let decryptedToken = shelf.apiToken; - try { - // Check if the token is encrypted (our new storage method format) - if (encryptionService.isEncryptedFormat(shelf.apiToken)) { - decryptedToken = encryptionService.decrypt(shelf.apiToken); - } - } catch (err) { - log.error( - `Failed to decrypt API token for user ${shelf.user.plexUsername}`, - ); - } - - let fetchedData: { listName: string; books: HardcoverApiBook[] }; - try { - fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId); - } catch (error) { - log.error( - `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - return; - } - - const books = fetchedData.books; - stats.booksFound += books.length; - log.info( - `Found ${books.length} books in list "${shelf.name}" (Hardcover API)`, - ); - - let lookupsThisCycle = 0; - const unlimitedLookups = maxLookups === 0; - - for (const book of books) { - let mapping = await prisma.hardcoverBookMapping.findUnique({ - where: { hardcoverBookId: book.bookId }, - }); - - if (!mapping) { - if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue; - - mapping = await performAudibleLookup(book, log); - lookupsThisCycle++; - stats.lookupsPerformed++; - - if (!mapping?.audibleAsin) continue; - } - - if (mapping.noMatch) { - if (mapping.lastSearchAt) { - const daysSinceSearch = - (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24); - if ( - daysSinceSearch >= NO_MATCH_RETRY_DAYS && - (unlimitedLookups || lookupsThisCycle < maxLookups) - ) { - log.info( - `Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`, - ); - mapping = await performAudibleLookup(book, log, mapping.id); - lookupsThisCycle++; - stats.lookupsPerformed++; - - if (!mapping?.audibleAsin) continue; - } else { - continue; - } - } else { - continue; - } - } - - if (mapping.audibleAsin) { - try { - const result = await createRequestForUser(shelf.user.id, { - asin: mapping.audibleAsin, - title: mapping.title, - author: mapping.author, - coverArtUrl: mapping.coverUrl || undefined, - }); - - if (result.success) { - stats.requestsCreated++; - log.info( - `Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`, - ); - } - } catch (error) { - log.error( - `Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - } - - // Collect enriched book data for display - const bookIds = books.map((b) => b.bookId); - const mappings = - bookIds.length > 0 - ? await prisma.hardcoverBookMapping.findMany({ - where: { hardcoverBookId: { in: bookIds } }, - select: { - hardcoverBookId: true, - audibleAsin: true, - title: true, - author: true, - coverUrl: true, - }, - }) - : []; - const mappingsByBookId = new Map(mappings.map((m) => [m.hardcoverBookId, m])); - - const matchedAsins = mappings - .map((m) => m.audibleAsin) - .filter((asin): asin is string => !!asin); - const cachedCovers = - matchedAsins.length > 0 - ? await prisma.audibleCache.findMany({ - where: { asin: { in: matchedAsins } }, - select: { asin: true, coverArtUrl: true, cachedCoverPath: true }, - }) - : []; - const coverByAsin = new Map( - cachedCovers - .filter((c) => c.cachedCoverPath || c.coverArtUrl) - .map((c) => { - let coverUrl = c.coverArtUrl || ''; - if (c.cachedCoverPath) { - const filename = c.cachedCoverPath.split('/').pop(); - coverUrl = `/api/cache/thumbnails/${filename}`; - } - return [c.asin, coverUrl] as const; - }), - ); - - const bookData = books - .map((b) => { - const mapping = mappingsByBookId.get(b.bookId); - const coverUrl = - coverByAsin.get(mapping?.audibleAsin || '') || - mapping?.coverUrl || - b.coverUrl; - if (!coverUrl) return null; - return { - coverUrl, - asin: mapping?.audibleAsin || null, - title: mapping?.title || b.title, - author: mapping?.author || b.author, - }; - }) - .filter((b): b is NonNullable => b !== null) - .slice(0, 8); - - const finalListName = - fetchedData.listName !== 'Hardcover List' - ? fetchedData.listName - : shelf.name; - - await prisma.hardcoverShelf.update({ - where: { id: shelf.id }, - data: { - name: finalListName, - lastSyncAt: new Date(), - bookCount: books.length, - coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, - }, - }); -} - -async function performAudibleLookup( - book: HardcoverApiBook, - log: - | ReturnType - | ReturnType, - existingMappingId?: string, -): Promise { - const audibleService = getAudibleService(); - - try { - const fullQuery = `${book.title} ${book.author}`; - log.info(`Searching Audible for: "${fullQuery}"`); - - let searchResult = await audibleService.search(fullQuery); - let firstResult = searchResult.results[0]; - - if (!firstResult?.asin) { - const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim(); - if (cleanTitle !== book.title) { - const cleanQuery = `${cleanTitle} ${book.author}`; - log.info( - `No results with full title, retrying without series info: "${cleanQuery}"`, - ); - searchResult = await audibleService.search(cleanQuery); - firstResult = searchResult.results[0]; - } - } - - if (firstResult?.asin) { - log.info( - `Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`, - ); - - const data = { - title: firstResult.title, - author: firstResult.author, - audibleAsin: firstResult.asin, - coverUrl: firstResult.coverArtUrl || book.coverUrl || null, - noMatch: false, - lastSearchAt: new Date(), - }; - - if (existingMappingId) { - return prisma.hardcoverBookMapping.update({ - where: { id: existingMappingId }, - data, - }); - } - return prisma.hardcoverBookMapping.create({ - data: { hardcoverBookId: book.bookId, ...data }, - }); - } - - log.info(`No Audible match for "${book.title}" by ${book.author}`); - - const noMatchData = { - title: book.title, - author: book.author, - coverUrl: book.coverUrl || null, - noMatch: true, - lastSearchAt: new Date(), - audibleAsin: null, - }; - - if (existingMappingId) { - return prisma.hardcoverBookMapping.update({ - where: { id: existingMappingId }, - data: noMatchData, - }); - } - return prisma.hardcoverBookMapping.create({ - data: { hardcoverBookId: book.bookId, ...noMatchData }, - }); - } catch (error) { - log.error( - `Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - - const errorData = { - title: book.title, - author: book.author, - coverUrl: book.coverUrl || null, - noMatch: true, - lastSearchAt: new Date(), - }; - - if (existingMappingId) { - return prisma.hardcoverBookMapping.update({ - where: { id: existingMappingId }, - data: errorData, - }); - } - return prisma.hardcoverBookMapping.create({ - data: { hardcoverBookId: book.bookId, ...errorData }, - }); - } -} diff --git a/src/lib/services/shelf-sync-core.service.ts b/src/lib/services/shelf-sync-core.service.ts new file mode 100644 index 0000000..69db7c8 --- /dev/null +++ b/src/lib/services/shelf-sync-core.service.ts @@ -0,0 +1,274 @@ +/** + * Component: Shelf Sync Core Service + * Documentation: documentation/backend/services/goodreads-sync.md + * + * Shared logic for all shelf providers: Audible lookup, noMatch retry, + * request creation, cover enrichment, and shelf metadata updates. + * Provider-specific services (Goodreads, Hardcover) call into this core. + */ + +import { prisma } from '@/lib/db'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { RMABLogger } from '@/lib/utils/logger'; +import { BookMapping } from '@/generated/prisma'; + +/** Default max Audible lookups per shelf per scheduled sync cycle */ +const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10; + +/** Days before retrying a noMatch book */ +const NO_MATCH_RETRY_DAYS = 7; + +/** Provider-agnostic book from any shelf source */ +export interface ShelfBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** Sync stats shared across all providers */ +export interface ShelfSyncStats { + shelvesProcessed: number; + booksFound: number; + lookupsPerformed: number; + requestsCreated: number; + errors: number; +} + +/** Common sync options */ +export interface ShelfSyncOptions { + shelfId?: string; + maxLookupsPerShelf?: number; +} + +type LoggerType = ReturnType | ReturnType; + +export function createEmptyStats(): ShelfSyncStats { + return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 }; +} + +export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void { + target.shelvesProcessed += source.shelvesProcessed; + target.booksFound += source.booksFound; + target.lookupsPerformed += source.lookupsPerformed; + target.requestsCreated += source.requestsCreated; + target.errors += source.errors; +} + +export function resolveMaxLookups(options: ShelfSyncOptions): number { + return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; +} + +/** + * Process a list of books from any provider: resolve to ASINs, create requests, + * enrich covers, and return book data for shelf metadata. + */ +export async function processShelfBooks( + provider: string, + books: ShelfBook[], + userId: string, + shelfId: string, + stats: ShelfSyncStats, + log: LoggerType, + maxLookups: number, +): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> { + stats.booksFound += books.length; + + let lookupsThisCycle = 0; + const unlimitedLookups = maxLookups === 0; + + for (const book of books) { + let mapping = await prisma.bookMapping.findUnique({ + where: { provider_externalBookId: { provider, externalBookId: book.bookId } }, + }); + + if (!mapping) { + if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue; + + mapping = await performAudibleLookup(provider, book, log); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } + + if (mapping.noMatch) { + if (mapping.lastSearchAt) { + const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24); + if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) { + log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`); + mapping = await performAudibleLookup(provider, book, log, mapping.id); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } else { + continue; + } + } else { + continue; + } + } + + if (mapping.audibleAsin) { + try { + const result = await createRequestForUser(userId, { + asin: mapping.audibleAsin, + title: mapping.title, + author: mapping.author, + coverArtUrl: mapping.coverUrl || undefined, + }); + + if (result.success) { + stats.requestsCreated++; + log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`); + } + } catch (error) { + log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } + + return enrichBookCovers(provider, books); +} + +/** + * Enrich book list with cached cover URLs from AudibleCache. + * Returns up to 8 books with the best available cover URL. + */ +async function enrichBookCovers( + provider: string, + books: ShelfBook[], +): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> { + const bookIds = books.map(b => b.bookId); + const mappings = bookIds.length > 0 + ? await prisma.bookMapping.findMany({ + where: { provider, externalBookId: { in: bookIds } }, + select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true }, + }) + : []; + const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, m])); + + const matchedAsins = mappings + .map(m => m.audibleAsin) + .filter((asin): asin is string => !!asin); + const cachedCovers = matchedAsins.length > 0 + ? await prisma.audibleCache.findMany({ + where: { asin: { in: matchedAsins } }, + select: { asin: true, coverArtUrl: true, cachedCoverPath: true }, + }) + : []; + const coverByAsin = new Map( + cachedCovers + .filter(c => c.cachedCoverPath || c.coverArtUrl) + .map(c => { + let coverUrl = c.coverArtUrl || ''; + if (c.cachedCoverPath) { + const filename = c.cachedCoverPath.split('/').pop(); + coverUrl = `/api/cache/thumbnails/${filename}`; + } + return [c.asin, coverUrl] as const; + }) + ); + + return books + .map(b => { + const mapping = mappingsByBookId.get(b.bookId); + const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl; + if (!coverUrl) return null; + return { + coverUrl, + asin: mapping?.audibleAsin || null, + title: mapping?.title || b.title, + author: mapping?.author || b.author, + }; + }) + .filter((b): b is NonNullable => b !== null) + .slice(0, 8); +} + +/** + * Search Audible for a book, persist the result to the unified BookMapping table. + */ +async function performAudibleLookup( + provider: string, + book: ShelfBook, + log: LoggerType, + existingMappingId?: string, +): Promise { + const audibleService = getAudibleService(); + + try { + const fullQuery = `${book.title} ${book.author}`; + log.info(`Searching Audible for: "${fullQuery}"`); + + let searchResult = await audibleService.search(fullQuery); + let firstResult = searchResult.results[0]; + + if (!firstResult?.asin) { + const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim(); + if (cleanTitle !== book.title) { + const cleanQuery = `${cleanTitle} ${book.author}`; + log.info(`No results with full title, retrying without series info: "${cleanQuery}"`); + searchResult = await audibleService.search(cleanQuery); + firstResult = searchResult.results[0]; + } + } + + if (firstResult?.asin) { + log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`); + + const data = { + title: firstResult.title, + author: firstResult.author, + audibleAsin: firstResult.asin, + coverUrl: firstResult.coverArtUrl || book.coverUrl || null, + noMatch: false, + lastSearchAt: new Date(), + }; + + if (existingMappingId) { + return prisma.bookMapping.update({ where: { id: existingMappingId }, data }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: book.bookId, ...data }, + }); + } + + log.info(`No Audible match for "${book.title}" by ${book.author}`); + + const noMatchData = { + title: book.title, + author: book.author, + coverUrl: book.coverUrl || null, + noMatch: true, + lastSearchAt: new Date(), + audibleAsin: null, + }; + + if (existingMappingId) { + return prisma.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: book.bookId, ...noMatchData }, + }); + } catch (error) { + log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); + + const errorData = { + title: book.title, + author: book.author, + coverUrl: book.coverUrl || null, + noMatch: true, + lastSearchAt: new Date(), + }; + + if (existingMappingId) { + return prisma.bookMapping.update({ where: { id: existingMappingId }, data: errorData }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: book.bookId, ...errorData }, + }); + } +} diff --git a/src/lib/utils/shelf-helpers.ts b/src/lib/utils/shelf-helpers.ts new file mode 100644 index 0000000..1b82528 --- /dev/null +++ b/src/lib/utils/shelf-helpers.ts @@ -0,0 +1,36 @@ +/** + * Component: Shelf Helpers + * Documentation: documentation/frontend/components.md + */ + +/** + * Parse a JSON string of cover/book data into a typed array. + * Returns an empty array on parse failure (graceful degradation). + */ +export function processBooks( + coverUrls: string | null, +): { coverUrl: string; asin: string | null; title: string; author: string }[] { + if (!coverUrls) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(coverUrls); + } catch { + return []; + } + + if (!Array.isArray(parsed)) return []; + + return parsed.map((item: unknown) => { + if (typeof item === 'string') { + return { coverUrl: item, asin: null, title: '', author: '' }; + } + const obj = item as Record; + return { + coverUrl: (obj.coverUrl as string) || '', + asin: (obj.asin as string) || null, + title: (obj.title as string) || '', + author: (obj.author as string) || '', + }; + }); +} diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 90cd6e2..cc8989f 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -46,7 +46,7 @@ export const createPrismaMock = () => ({ bookDateRecommendation: createModelMock(), bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), - goodreadsBookMapping: createModelMock(), + bookMapping: createModelMock(), hardcoverShelf: createModelMock(), work: createModelMock(), workAsin: createModelMock(), diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index 8ff7adb..b294e81 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,7 +18,6 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), - addMonitorRssFeedsJob: vi.fn(), addSyncShelvesJob: vi.fn(), })); From 7f706e806fadd5974dee2cd03cd3e364db53108c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 10:28:52 -0500 Subject: [PATCH 16/19] Use hardcover-api service with pagination Replace the old hardcover sync usage with a new hardcover-api.service implementation that adds types, a reusable extractBooks helper, and paginated GraphQL queries (limit/offset) to fully fetch status and list books. Update API route import to use the new service. Fix ManageShelfModal to initialize rssUrl/listId as empty strings. Update tests to mock the new service and add encryption format helper mocking. --- src/app/api/user/hardcover-shelves/route.ts | 2 +- src/components/ui/ManageShelfModal.tsx | 4 +- src/lib/services/hardcover-api.service.ts | 207 +++++++++++------- tests/api/hardcover-shelves-id.routes.test.ts | 11 + tests/api/hardcover-shelves.routes.test.ts | 2 +- 5 files changed, 145 insertions(+), 81 deletions(-) diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 725870b..56feb35 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service'; +import { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { z } from 'zod'; diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx index 7dce0a6..5799907 100644 --- a/src/components/ui/ManageShelfModal.tsx +++ b/src/components/ui/ManageShelfModal.tsx @@ -19,8 +19,8 @@ interface ManageShelfModalProps { } export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { - const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : ''); - const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); + const [rssUrl, setRssUrl] = useState(''); + const [listId, setListId] = useState(''); const [apiToken, setApiToken] = useState(''); const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); diff --git a/src/lib/services/hardcover-api.service.ts b/src/lib/services/hardcover-api.service.ts index d8e4df8..a0441da 100644 --- a/src/lib/services/hardcover-api.service.ts +++ b/src/lib/services/hardcover-api.service.ts @@ -17,6 +17,48 @@ export interface HardcoverApiBook { coverUrl?: string; } +/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */ +interface HardcoverBookNode { + id?: number; + title?: string; + cached_image?: string | { url?: string }; + image?: { url?: string }; + contributions?: Array<{ author?: { name?: string } }>; +} + +/** Shape of a list object returned from the Hardcover GraphQL API */ +interface HardcoverListData { + name?: string; + list_books?: Array<{ book?: HardcoverBookNode }>; +} + +const PAGE_SIZE = 100; + +/** Extract HardcoverApiBook[] from an array of book-containing items */ +function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] { + const books: HardcoverApiBook[] = []; + for (const item of items) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + return books; +} + /** * Fetch a Hardcover List using their GraphQL API. * This handles both 'status_id' user_books or 'list_id' list_books queries. @@ -32,9 +74,9 @@ export async function fetchHardcoverList( if (isStatus) { const statusId = parseInt(listIdStr.replace('status-', ''), 10); const query = ` - query GetStatusBooks($statusId: Int!) { + query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) { me { - user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) { + user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) { book { id title @@ -53,27 +95,6 @@ export async function fetchHardcoverList( } `; - const response = await axios.post( - HARDCOVER_API_URL, - { query, variables: { statusId } }, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - timeout: 30000, - }, - ); - - if (response.data?.errors) { - throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, - ); - } - - const userBooks = response.data?.data?.me?.[0]?.user_books || []; - let listName = 'Hardcover Status List'; - // Map status numbers to names const statusNames: Record = { 1: 'Want to Read', @@ -81,30 +102,41 @@ export async function fetchHardcoverList( 3: 'Read', 4: 'Did Not Finish', }; - listName = statusNames[statusId] || `Status ${statusId}`; + const listName = statusNames[statusId] || `Status ${statusId}`; - const books: HardcoverApiBook[] = []; - for (const item of userBooks) { - const book = item.book; - if (!book || !book.id) continue; + const allBooks: HardcoverApiBook[] = []; + let offset = 0; - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const cachedImg = book.cached_image; - const coverUrl = - (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || - book.image?.url || - undefined; + // Paginate until fewer results than PAGE_SIZE are returned + while (true) { + const response = await axios.post( + HARDCOVER_API_URL, + { query, variables: { statusId, limit: PAGE_SIZE, offset } }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); + } + + const userBooks: Array<{ book?: HardcoverBookNode }> = + response.data?.data?.me?.[0]?.user_books || []; + const pageBooks = extractBooks(userBooks); + allBooks.push(...pageBooks); + + if (userBooks.length < PAGE_SIZE) break; + offset += PAGE_SIZE; } - return { listName, books }; + return { listName, books: allBooks }; } else { // Custom list query // - URL with @username → query that user's lists by slug @@ -137,7 +169,7 @@ export async function fetchHardcoverList( const listBookFields = ` name - list_books(limit: 100, order_by: {id: desc}) { + list_books(limit: $limit, offset: $offset, order_by: {id: desc}) { book { id title cached_image image { url } contributions { author { name } } @@ -147,7 +179,7 @@ export async function fetchHardcoverList( // Numeric ID: globally unique, query the lists table directly const queryById = ` - query GetListBooks($listId: Int!) { + query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) { lists(where: {id: {_eq: $listId}}, limit: 1) { ${listBookFields} } @@ -156,7 +188,7 @@ export async function fetchHardcoverList( // Slug with username: query through the users table to scope to that user const queryByUserSlug = ` - query GetUserListBySlug($username: citext!, $slug: String!) { + query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) { users(where: {username: {_eq: $username}}, limit: 1) { lists(where: {slug: {_eq: $slug}}, limit: 1) { ${listBookFields} @@ -167,7 +199,7 @@ export async function fetchHardcoverList( // Bare slug (no username): scope to the authenticated user via `me` const queryByMySlug = ` - query GetMyListBySlug($slug: String!) { + query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) { me { lists(where: {slug: {_eq: $slug}}, limit: 1) { ${listBookFields} @@ -177,24 +209,25 @@ export async function fetchHardcoverList( `; let activeQuery: string; - let variables: Record; + let baseVariables: Record; if (isIntId) { activeQuery = queryById; - variables = { listId: parseInt(listIdStr, 10) }; + baseVariables = { listId: parseInt(listIdStr, 10) }; } else if (extractedUsername) { activeQuery = queryByUserSlug; - variables = { username: extractedUsername, slug: extractedSlug }; + baseVariables = { username: extractedUsername, slug: extractedSlug }; } else { activeQuery = queryByMySlug; - variables = { slug: extractedSlug }; + baseVariables = { slug: extractedSlug }; } - const response = await axios.post( + // First request to discover list metadata and first page of books + const firstResponse = await axios.post( HARDCOVER_API_URL, { query: activeQuery, - variables, + variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 }, }, { headers: { @@ -205,21 +238,21 @@ export async function fetchHardcoverList( }, ); - if (response.data?.errors) { + if (firstResponse.data?.errors) { throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, + `Hardcover API Error: ${firstResponse.data.errors[0]?.message}`, ); } // Extract lists array from the response based on which query was used - let listsData: any[]; + let listsData: HardcoverListData[]; if (isIntId) { - listsData = response.data?.data?.lists || []; + listsData = firstResponse.data?.data?.lists || []; } else if (extractedUsername) { - const users = response.data?.data?.users || []; + const users = firstResponse.data?.data?.users || []; listsData = users[0]?.lists || []; } else { - listsData = response.data?.data?.me?.[0]?.lists || []; + listsData = firstResponse.data?.data?.me?.[0]?.lists || []; } if (listsData.length === 0) { @@ -235,29 +268,49 @@ export async function fetchHardcoverList( } const listName = listsData[0].name || 'Hardcover List'; - const listBooks = listsData[0].list_books || []; + const firstPageItems = listsData[0].list_books || []; + const allBooks = extractBooks(firstPageItems); - const books: HardcoverApiBook[] = []; - for (const item of listBooks) { - const book = item.book; - if (!book || !book.id) continue; + // Paginate if first page was full + if (firstPageItems.length >= PAGE_SIZE) { + let offset = PAGE_SIZE; - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const cachedImg = book.cached_image; - const coverUrl = - (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || - book.image?.url || - undefined; + while (true) { + const pageResponse = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables: { ...baseVariables, limit: PAGE_SIZE, offset }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); + if (pageResponse.data?.errors) break; + + let pageListsData: HardcoverListData[]; + if (isIntId) { + pageListsData = pageResponse.data?.data?.lists || []; + } else if (extractedUsername) { + const users = pageResponse.data?.data?.users || []; + pageListsData = users[0]?.lists || []; + } else { + pageListsData = pageResponse.data?.data?.me?.[0]?.lists || []; + } + + const pageItems = pageListsData[0]?.list_books || []; + allBooks.push(...extractBooks(pageItems)); + + if (pageItems.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } } - return { listName, books }; + return { listName, books: allBooks }; } } diff --git a/tests/api/hardcover-shelves-id.routes.test.ts b/tests/api/hardcover-shelves-id.routes.test.ts index 338ce2d..7d084ea 100644 --- a/tests/api/hardcover-shelves-id.routes.test.ts +++ b/tests/api/hardcover-shelves-id.routes.test.ts @@ -16,8 +16,11 @@ const jobQueueMock = vi.hoisted(() => ({ const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((s: string) => `enc:${s}`), decrypt: vi.fn((s: string) => s.replace('enc:', '')), + isEncryptedFormat: vi.fn((s: string) => s.startsWith('enc:')), })); +const fetchHardcoverListMock = vi.hoisted(() => vi.fn()); + vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, })); @@ -34,6 +37,10 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); +vi.mock('@/lib/services/hardcover-api.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + const SHELF = { id: 'hc-shelf-1', userId: 'user-1', @@ -106,6 +113,10 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => { vi.clearAllMocks(); authRequest = { user: { id: 'user-1', role: 'user' } }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + encryptionMock.isEncryptedFormat.mockImplementation((s: string) => s.startsWith('enc:')); + encryptionMock.encrypt.mockImplementation((s: string) => `enc:${s}`); + encryptionMock.decrypt.mockImplementation((s: string) => s.replace('enc:', '')); + fetchHardcoverListMock.mockResolvedValue({ listName: 'Test List', books: [] }); }); it('returns 404 when list does not exist', async () => { diff --git a/tests/api/hardcover-shelves.routes.test.ts b/tests/api/hardcover-shelves.routes.test.ts index fae8242..176bf1e 100644 --- a/tests/api/hardcover-shelves.routes.test.ts +++ b/tests/api/hardcover-shelves.routes.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); -vi.mock('@/lib/services/hardcover-sync.service', () => ({ +vi.mock('@/lib/services/hardcover-api.service', () => ({ fetchHardcoverList: fetchHardcoverListMock, })); From c29cfa3a074cc7dd78027917bfd696a8b1835e81 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 10:55:37 -0500 Subject: [PATCH 17/19] Fix token handling, modal behavior, and pagination Multiple fixes and improvements: - src/app/api/user/hardcover-shelves/[id]/route.ts: Make token testing more robust by using the existing shelf.apiToken when no new token is provided, attempt decryption only when needed, and gracefully fall back on decryption errors. - src/components/ui/AddShelfModal.tsx: Simplify token handling by passing the trimmed token directly to addHardcover (remove client-side 'Bearer ' stripping). - src/components/ui/ManageShelfModal.tsx: Stabilize form reset effect by depending on shelf?.id to avoid unnecessary re-renders when the shelf object changes identity. - src/components/ui/Modal.tsx: Simplify modal rendering by removing the mounted state and createPortal usage, cleaning up imports and rendering directly. - src/lib/services/hardcover-api.service.ts: Add a logger, introduce a MAX_PAGES cap and page counters to prevent unbounded pagination loops, and log/break when the API returns errors during pagination. These changes improve reliability (token handling and pagination safety), reduce unnecessary renders, and simplify modal lifecycle. --- .../api/user/hardcover-shelves/[id]/route.ts | 15 +++++++++------ src/components/ui/AddShelfModal.tsx | 6 +----- src/components/ui/ManageShelfModal.tsx | 4 ++-- src/components/ui/Modal.tsx | 19 +++++-------------- src/lib/services/hardcover-api.service.ts | 17 ++++++++++++++--- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index 438cbcb..b0a7916 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -106,13 +106,16 @@ export async function PATCH( // Validate token/listId by fetching the list before saving if (cleanedToken || newListId) { const encryptionService = getEncryptionService(); - const tokenToTest = cleanedToken || (() => { + let tokenToTest = cleanedToken || shelf.apiToken; + if (!cleanedToken) { try { - return encryptionService.isEncryptedFormat(shelf.apiToken) - ? encryptionService.decrypt(shelf.apiToken) - : shelf.apiToken; - } catch { return shelf.apiToken; } - })(); + if (encryptionService.isEncryptedFormat(shelf.apiToken)) { + tokenToTest = encryptionService.decrypt(shelf.apiToken); + } + } catch { + // Decryption failed, fall back to raw token + } + } const listIdToTest = newListId || shelf.listId; try { diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx index 11cab78..537235f 100644 --- a/src/components/ui/AddShelfModal.tsx +++ b/src/components/ui/AddShelfModal.tsx @@ -77,11 +77,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { setRssUrl(''); } else { const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim(); - let cleanedToken = apiToken.trim(); - if (cleanedToken.toLowerCase().startsWith('bearer ')) { - cleanedToken = cleanedToken.slice(7).trim(); - } - const shelf = await addHardcover(cleanedToken, finalId); + const shelf = await addHardcover(apiToken.trim(), finalId); setSuccessMessage(`Added list "${shelf.name}" successfully!`); setApiToken(''); setCustomListId(''); diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx index 5799907..3f58745 100644 --- a/src/components/ui/ManageShelfModal.tsx +++ b/src/components/ui/ManageShelfModal.tsx @@ -26,14 +26,14 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf(); - // Reset form when shelf changes + // Reset form when shelf changes (use shelf?.id for stable reference) React.useEffect(() => { if (shelf) { setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : ''); setListId(shelf.type === 'hardcover' ? shelf.sourceId : ''); setApiToken(''); } - }, [shelf]); + }, [shelf?.id]); if (!shelf) return null; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 449e25d..baecde5 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -5,8 +5,7 @@ 'use client'; -import React, { useEffect, useRef, useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useEffect, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils/cn'; interface ModalProps { @@ -26,12 +25,6 @@ export function Modal({ size = 'md', showCloseButton = true, }: ModalProps) { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - // Use ref to avoid re-running effect when onClose changes const onCloseRef = useRef(onClose); onCloseRef.current = onClose; @@ -60,7 +53,7 @@ export function Modal({ }; }, [isOpen, handleClose]); - if (!isOpen || !mounted) return null; + if (!isOpen) return null; const sizeClasses = { sm: 'max-w-md', @@ -70,8 +63,8 @@ export function Modal({ full: 'max-w-[95vw]', }; - const content = ( -
+ return ( +
{/* Backdrop */}
e.stopPropagation()} > @@ -123,6 +116,4 @@ export function Modal({
); - - return createPortal(content, document.body); } diff --git a/src/lib/services/hardcover-api.service.ts b/src/lib/services/hardcover-api.service.ts index a0441da..1c568a3 100644 --- a/src/lib/services/hardcover-api.service.ts +++ b/src/lib/services/hardcover-api.service.ts @@ -7,7 +7,9 @@ */ import axios from 'axios'; +import { RMABLogger } from '@/lib/utils/logger'; +const logger = RMABLogger.create('HardcoverAPI'); const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; export interface HardcoverApiBook { @@ -33,6 +35,7 @@ interface HardcoverListData { } const PAGE_SIZE = 100; +const MAX_PAGES = 50; /** Extract HardcoverApiBook[] from an array of book-containing items */ function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] { @@ -106,9 +109,10 @@ export async function fetchHardcoverList( const allBooks: HardcoverApiBook[] = []; let offset = 0; + let page = 0; // Paginate until fewer results than PAGE_SIZE are returned - while (true) { + while (++page <= MAX_PAGES) { const response = await axios.post( HARDCOVER_API_URL, { query, variables: { statusId, limit: PAGE_SIZE, offset } }, @@ -274,8 +278,9 @@ export async function fetchHardcoverList( // Paginate if first page was full if (firstPageItems.length >= PAGE_SIZE) { let offset = PAGE_SIZE; + let page = 1; // first page already fetched - while (true) { + while (++page <= MAX_PAGES) { const pageResponse = await axios.post( HARDCOVER_API_URL, { @@ -291,7 +296,13 @@ export async function fetchHardcoverList( }, ); - if (pageResponse.data?.errors) break; + if (pageResponse.data?.errors) { + logger.warn('Hardcover pagination interrupted by API error', { + errors: pageResponse.data.errors, + offset, + }); + break; + } let pageListsData: HardcoverListData[]; if (isIntId) { From efb4f640148a5e0696843865dd93bdcbae1038d4 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 15:53:50 -0500 Subject: [PATCH 18/19] Count errors and skip shelf on token decrypt fail When decrypting a user's API token fails, increment stats.errors and continue to the next shelf instead of proceeding. This ensures failed decryptions are tracked in metrics and prevents attempting to fetch data with an invalid token. --- src/lib/services/hardcover-sync.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index edad091..11bb4f3 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -70,6 +70,8 @@ export async function processHardcoverShelves( } } catch (err) { log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`); + stats.errors++; + continue; } let fetchedData: { listName: string; books: HardcoverApiBook[] }; From 85aa80938a25894d77005856c08fc42033c6c377 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 16:00:36 -0500 Subject: [PATCH 19/19] Remove AddShelfModal usage from Header Remove AddShelfModal from the Header component: delete its import, the showAddShelfModal state hook, the "Add Shelf" menu button, and the AddShelfModal modal render. Cleans up unused state and UI related to adding shelves; no other behavior changes. --- src/components/layout/Header.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index fe05a2d..3623629 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -12,7 +12,6 @@ import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/Button'; import { VersionBadge } from '@/components/ui/VersionBadge'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; -import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; export function Header() { @@ -21,7 +20,6 @@ export function Header() { const [showMobileMenu, setShowMobileMenu] = useState(false); const [showBookDate, setShowBookDate] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); - const [showAddShelfModal, setShowAddShelfModal] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); @@ -91,15 +89,6 @@ export function Header() { > Profile - {canChangePassword && (