diff --git a/.gitignore b/.gitignore index e49ac7f..a834ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # IDE .idea +.vscode # Dependencies /node_modules @@ -55,4 +56,4 @@ next-env.d.ts /test-media /test-data /bookdrop -dockerfile.patch \ No newline at end of file +dockerfile.patch 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 6a39100..2dee339 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") @@ -531,3 +532,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/public/goodreads-icon.png b/public/goodreads-icon.png new file mode 100644 index 0000000..cbfa21c Binary files /dev/null and b/public/goodreads-icon.png differ diff --git a/public/hardcover-icon.svg b/public/hardcover-icon.svg new file mode 100644 index 0000000..6ca99f3 --- /dev/null +++ b/public/hardcover-icon.svg @@ -0,0 +1 @@ + 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/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 9736619..8626fc0 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -139,8 +139,8 @@ export async function POST(request: NextRequest) { // 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) }); } 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..b7f94a5 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -0,0 +1,144 @@ +/** + * 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'; +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) + */ +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 }, + ); + } + }); +} + +/** + * 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/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts new file mode 100644 index 0000000..4390220 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -0,0 +1,216 @@ +/** + * 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 { getEncryptionService } from '@/lib/services/encryption.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(); + let { listId, apiToken } = AddShelfSchema.parse(body); + + // Clean up token in case user pasted "Bearer " prefix + apiToken = apiToken.trim(); + if (apiToken.toLowerCase().startsWith('bearer ')) { + apiToken = apiToken.slice(7).trim(); + } + + // 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 encryptionService = getEncryptionService(); + const encryptedToken = encryptionService.encrypt(apiToken); + + const shelf = await prisma.hardcoverShelf.create({ + data: { + userId: req.user.id, + name: listName, + listId, + apiToken: encryptedToken, + 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.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 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/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 1d88640..5d69501 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,7 +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 { ShelvesSection } from '@/components/profile/ShelvesSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -139,8 +139,8 @@ export default function ProfilePage() { - {/* Goodreads Shelves */} - + {/* Generic Shelves Section */} + {/* Active Downloads */} {activeDownloads.length > 0 && ( diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index eff6201..fe05a2d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -12,7 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/Button'; import { VersionBadge } from '@/components/ui/VersionBadge'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; +import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; export function Header() { @@ -21,8 +21,9 @@ export function Header() { const [showMobileMenu, setShowMobileMenu] = useState(false); const [showBookDate, setShowBookDate] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); - const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false); - const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); + const [showAddShelfModal, setShowAddShelfModal] = useState(false); + const { containerRef, dropdownRef, positionAbove, style } = + useSmartDropdownPosition(showUserMenu); // Check if user can change password (local users only) const canChangePassword = user?.authProvider === 'local'; @@ -44,16 +45,14 @@ export function Header() { const response = await fetch('/api/bookdate/config', { headers: { - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, }); const data = await response.json(); // Show BookDate to any user with verified and enabled configuration setShowBookDate( - data.config && - data.config.isVerified && - data.config.isEnabled + data.config && data.config.isVerified && data.config.isEnabled, ); } catch (error) { console.error('Failed to check BookDate config:', error); @@ -95,11 +94,11 @@ export function Header() { {canChangePassword && ( @@ -327,7 +356,9 @@ export function Header() { {/* User menu dropdown (rendered via portal) */} - {typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)} + {typeof window !== 'undefined' && + userMenuDropdown && + createPortal(userMenuDropdown, document.body)} {/* Change Password Modal */} setShowChangePasswordModal(false)} /> - {/* Add Goodreads Shelf Modal */} - setShowAddGoodreadsModal(false)} + {/* Add Shelf Modal */} + setShowAddShelfModal(false)} /> ); diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx similarity index 57% rename from src/components/profile/GoodreadsShelvesSection.tsx rename to src/components/profile/ShelvesSection.tsx index 0b8d5e5..7072270 100644 --- a/src/components/profile/GoodreadsShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -1,16 +1,21 @@ /** - * Component: Goodreads Shelves Section (Profile Page) + * Component: Combined 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 { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; +import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; 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 { if (!dateStr) return 'Never'; @@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string { return `${diffDays}d ago`; } -export function GoodreadsShelvesSection() { - const { shelves, isLoading } = useGoodreadsShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf(); +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 [selectedAsin, setSelectedAsin] = useState(null); - const handleDelete = async (shelfId: string) => { + 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 { - 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 */}
-
- - +
+ +

- Goodreads Shelves + Shelves

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

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

)}
- + {shelves.length > 0 && ( + + )}
{/* Content */} @@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() { 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)} + onManage={() => setManageShelf(shelf)} onBookClick={(asin) => setSelectedAsin(asin)} /> ))}
) : ( - setShowAddModal(true)} /> + setShowAddShelf(true)} /> )} - setShowAddModal(false)} + {/* Modals */} + setShowAddShelf(false)} + /> + + setManageShelf(null)} + shelf={manageShelf} /> - {/* Audiobook Detail Modal (read-only) */} {selectedAsin && ( void }) { return (
-
- - +
+ +
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) { Connect your reading list

- Link a Goodreads shelf 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.

@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { key={i} className={cn( 'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', - squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' + squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]', )} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }} /> @@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { /* ─── Shelf Card ─── */ interface ShelfCardProps { - shelf: GoodreadsShelf; + shelf: GenericShelf; squareCovers: boolean; isDeleting: boolean; isConfirmingDelete: boolean; onDelete: () => void; onConfirmDelete: () => void; onCancelDelete: () => void; + onManage: () => void; onBookClick: (asin: string) => void; } @@ -197,20 +265,44 @@ function ShelfCard({ onDelete, onConfirmDelete, onCancelDelete, + onManage, 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 remainingCount = Math.max( + 0, + (shelf.bookCount || 0) - displayBooks.length, + ); const isSyncing = !shelf.lastSyncAt; + const providerIcon = + shelf.type === 'goodreads' ? ( + Goodreads + ) : ( + Hardcover + ); + return (
{/* Top: Shelf info + actions */} -
+
-

- {shelf.name} +

+ {shelf.name} {providerIcon}

{shelf.bookCount != null && ( @@ -259,22 +351,58 @@ function ShelfCard({
) : ( - +
+ + +
)}
{/* Bottom: Stacked book covers */} {hasCovers ? ( - + ) : isSyncing ? (
{[...Array(3)].map((_, i) => ( @@ -282,7 +410,7 @@ function ShelfCard({ key={i} className={cn( 'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', - squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' + squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]', )} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }} /> @@ -322,7 +450,7 @@ function CoverStack({ 'transition-all duration-300 ease-out', hoveredIndex === i && 'scale-[1.18] shadow-xl', coverSize, - book.asin ? 'cursor-pointer' : 'cursor-default' + book.asin ? 'cursor-pointer' : 'cursor-default', )} style={{ marginLeft: i > 0 ? '-16px' : 0, @@ -331,7 +459,11 @@ function CoverStack({ onMouseEnter={() => setHoveredIndex(i)} onMouseLeave={() => setHoveredIndex(null)} onClick={() => book.asin && onBookClick(book.asin)} - title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} + title={ + book.asin + ? `${book.title}${book.author ? ` by ${book.author}` : ''}` + : undefined + } > 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/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx new file mode 100644 index 0000000..5e2a515 --- /dev/null +++ b/src/components/ui/AddShelfModal.tsx @@ -0,0 +1,366 @@ +/** + * 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' ? ( + <> +
+ 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. +

+
+ + )} +
+ + {/* 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/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/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); } 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 new file mode 100644 index 0000000..f9a4bcc --- /dev/null +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -0,0 +1,188 @@ +/** + * 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 }; +} + +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 }; +} diff --git a/src/lib/hooks/useShelves.ts b/src/lib/hooks/useShelves.ts new file mode 100644 index 0000000..f8b7f26 --- /dev/null +++ b/src/lib/hooks/useShelves.ts @@ -0,0 +1,40 @@ +/** + * Component: Shelves Hook + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import useSWR from 'swr'; +import { useAuth } from '@/contexts/AuthContext'; +import { fetchWithAuth } from '@/lib/utils/api'; +import { ShelfBook } from './useGoodreadsShelves'; + +export interface GenericShelf { + id: string; + type: 'goodreads' | 'hardcover'; + name: string; + sourceId: string; // Either rssUrl or listId + lastSyncAt: string | null; + createdAt: string; + bookCount: number | null; + books: ShelfBook[]; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +export function useShelves() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/shelves' : null; + + const { data, error, isLoading } = useSWR(endpoint, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as GenericShelf[], + isLoading, + error, + }; +} diff --git a/src/lib/processors/sync-goodreads-shelves.processor.ts b/src/lib/processors/sync-goodreads-shelves.processor.ts deleted file mode 100644 index 21f25c1..0000000 --- a/src/lib/processors/sync-goodreads-shelves.processor.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Component: Sync Goodreads Shelves Processor - * Documentation: documentation/backend/services/scheduler.md - * - * Dedicated processor for syncing Goodreads shelf RSS feeds. - * Resolves books to Audible ASINs and creates requests. - */ - -import { RMABLogger } from '../utils/logger'; - -export interface SyncGoodreadsShelvesPayload { - jobId?: string; - scheduledJobId?: string; - /** If set, only process this specific shelf (used for immediate sync on add) */ - shelfId?: string; - /** Max Audible lookups per shelf. 0 = unlimited. */ - maxLookupsPerShelf?: number; -} - -export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise { - const { jobId, shelfId, maxLookupsPerShelf } = payload; - const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves'); - - logger.info(shelfId - ? `Starting immediate Goodreads sync for shelf ${shelfId}...` - : 'Starting scheduled Goodreads shelves sync...' - ); - - const { processGoodreadsShelves } = await import('../services/goodreads-sync.service'); - const stats = await processGoodreadsShelves(logger, { - shelfId, - maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), - }); - - logger.info('Goodreads sync complete', { stats }); - - return { - success: true, - message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced', - ...stats, - }; -} diff --git a/src/lib/processors/sync-shelves.processor.ts b/src/lib/processors/sync-shelves.processor.ts new file mode 100644 index 0000000..50c76a0 --- /dev/null +++ b/src/lib/processors/sync-shelves.processor.ts @@ -0,0 +1,96 @@ +/** + * Component: Sync Shelves Processor + * Documentation: documentation/backend/services/scheduler.md + * + * Dedicated processor for syncing all reading shelves (Goodreads, Hardcover). + * Resolves books to Audible ASINs and creates requests. + */ + +import { RMABLogger } from '../utils/logger'; + +export interface SyncShelvesPayload { + jobId?: string; + scheduledJobId?: string; + /** If set, only process this specific shelf (used for immediate sync on add) */ + shelfId?: string; + /** The type of shelf, if shelfId is specified */ + shelfType?: 'goodreads' | 'hardcover'; + /** Max Audible lookups per shelf. 0 = unlimited. */ + maxLookupsPerShelf?: number; +} + +export async function processSyncShelves( + payload: SyncShelvesPayload, +): Promise { + const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload; + const logger = RMABLogger.forJob(jobId, 'SyncShelves'); + + const stats = { + shelvesProcessed: 0, + booksFound: 0, + lookupsPerformed: 0, + requestsCreated: 0, + errors: 0, + }; + + logger.info( + shelfId + ? `Starting immediate ${shelfType} sync for list ${shelfId}...` + : 'Starting scheduled shelves sync...', + ); + + const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads'; + const shouldSyncHardcover = !shelfType || shelfType === 'hardcover'; + + if (shouldSyncGoodreads) { + try { + const { processGoodreadsShelves } = + await import('../services/goodreads-sync.service'); + const grStats = await processGoodreadsShelves(logger, { + shelfId: shelfType === 'goodreads' ? shelfId : undefined, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + stats.shelvesProcessed += grStats.shelvesProcessed; + stats.booksFound += grStats.booksFound; + stats.lookupsPerformed += grStats.lookupsPerformed; + stats.requestsCreated += grStats.requestsCreated; + stats.errors += grStats.errors; + } catch (error) { + logger.error('Goodreads sync failed', { + error: error instanceof Error ? error.message : String(error), + }); + stats.errors++; + } + } + + if (shouldSyncHardcover) { + try { + const { processHardcoverShelves } = + await import('../services/hardcover-sync.service'); + const hcStats = await processHardcoverShelves(logger, { + shelfId: shelfType === 'hardcover' ? shelfId : undefined, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + stats.shelvesProcessed += hcStats.shelvesProcessed; + stats.booksFound += hcStats.booksFound; + stats.lookupsPerformed += hcStats.lookupsPerformed; + stats.requestsCreated += hcStats.requestsCreated; + stats.errors += hcStats.errors; + } catch (error) { + logger.error('Hardcover sync failed', { + error: error instanceof Error ? error.message : String(error), + }); + stats.errors++; + } + } + + logger.info('Shelves sync complete', { stats }); + + return { + success: true, + message: shelfId ? `${shelfType} list synced` : 'Reading shelves 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..e644d14 --- /dev/null +++ b/src/lib/services/hardcover-sync.service.ts @@ -0,0 +1,598 @@ +/** + * 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 { getEncryptionService } from '@/lib/services/encryption.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[] }> { + // 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 { + 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})`, + ); + + 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/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 6617a63..e729762 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -26,7 +26,7 @@ export type JobType = | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' - | 'sync_goodreads_shelves' + | 'sync_reading_shelves' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -107,9 +107,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload { scheduledJobId?: string; } -export interface SyncGoodreadsShelvesPayload extends JobPayload { +export interface SyncShelvesPayload extends JobPayload { scheduledJobId?: string; shelfId?: string; + shelfType?: 'goodreads' | 'hardcover'; maxLookupsPerShelf?: number; } @@ -378,10 +379,10 @@ export class JobQueueService { 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_reading_shelves', 1, async (job: BullJob) => { + const { processSyncShelves } = await import('../processors/sync-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves'); + return await processSyncShelves(payloadWithJobId); }); // Send notification processor @@ -750,16 +751,17 @@ export class JobQueueService { } /** - * Add sync Goodreads shelves job + * Add sync reading shelves job */ - async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise { + async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise { return await this.addJob( - 'sync_goodreads_shelves', + 'sync_reading_shelves', { scheduledJobId, shelfId, + shelfType, maxLookupsPerShelf, - } as SyncGoodreadsShelvesPayload, + } as SyncShelvesPayload, { priority: 7, } diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index f64fd6a..a6f8436 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,7 +10,7 @@ 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_reading_shelves'; export interface ScheduledJob { id: string; @@ -59,6 +59,9 @@ export class SchedulerService { }); } + // Clean up deprecated scheduled jobs + await this.cleanupDeprecatedJobs(); + // Create default jobs if they don't exist await this.ensureDefaultJobs(); @@ -127,8 +130,8 @@ export class SchedulerService { payload: {}, }, { - name: 'Sync Goodreads Shelves', - type: 'sync_goodreads_shelves' as ScheduledJobType, + name: 'Sync Reading Shelves', + type: 'sync_reading_shelves' as ScheduledJobType, schedule: '0 */6 * * *', // Every 6 hours enabled: true, // Enable by default payload: {}, @@ -167,6 +170,31 @@ export class SchedulerService { } } + /** + * Remove any old jobs that are no longer supported + */ + private async cleanupDeprecatedJobs(): Promise { + try { + const deprecatedTypes = ['sync_goodreads_shelves']; + + const obsoleteJobs = await prisma.scheduledJob.findMany({ + where: { type: { in: deprecatedTypes } }, + }); + + for (const job of obsoleteJobs) { + if (job.enabled) { + await this.unscheduleJob(job); + } + await prisma.scheduledJob.delete({ where: { id: job.id } }); + logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`); + } + } catch (error) { + logger.error('Failed to cleanup deprecated scheduled jobs', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** * Schedule all enabled jobs */ @@ -350,8 +378,8 @@ export class SchedulerService { case 'monitor_rss_feeds': bullJobId = await this.triggerMonitorRssFeeds(job); break; - case 'sync_goodreads_shelves': - bullJobId = await this.triggerSyncGoodreadsShelves(job); + case 'sync_reading_shelves': + bullJobId = await this.triggerSyncShelves(job); break; default: throw new Error(`Unknown job type: ${job.type}`); @@ -622,10 +650,10 @@ export class SchedulerService { } /** - * Trigger Goodreads shelves sync + * Trigger Reading shelves sync */ - private async triggerSyncGoodreadsShelves(job: any): Promise { - return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id); + private async triggerSyncShelves(job: any): Promise { + return await this.jobQueue.addSyncShelvesJob(job.id); } } 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(), }); 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..ff852b7 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,7 +75,7 @@ describe('SchedulerService', () => { enabled: true, }, ]) - .mockResolvedValueOnce([]); + .mockResolvedValue([]); // triggerOverdueJobs const { SchedulerService } = await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); @@ -289,7 +292,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',