diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts index 7f5a226..ce7f54d 100644 --- a/src/app/api/user/goodreads-shelves/[id]/route.ts +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -91,7 +91,7 @@ export async function PATCH( try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id); } catch (error) { logger.error('Failed to trigger immediate list sync', { error: error instanceof Error ? error.message : String(error), diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 8626fc0..2acf3bd 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -139,7 +139,7 @@ export async function POST(request: NextRequest) { // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id); 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 index b0a7916..8141a07 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -17,6 +17,7 @@ const logger = RMABLogger.create('API.HardcoverShelves'); const UpdateHardcoverSchema = z.object({ listId: z.string().min(1, 'List ID is required').optional(), apiToken: z.string().optional(), + forceSync: z.boolean().optional(), }); /** @@ -89,10 +90,10 @@ export async function PATCH( } const body = await request.json(); - const { listId, apiToken } = UpdateHardcoverSchema.parse(body); + const { listId, apiToken, forceSync } = UpdateHardcoverSchema.parse(body); const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {}; - let needsResync = false; + let needsResync = !!forceSync; let cleanedToken: string | undefined; if (apiToken && apiToken.trim() !== '') { diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 56feb35..4289ce7 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -148,7 +148,7 @@ export async function POST(request: NextRequest) { // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id); logger.info( `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, ); diff --git a/src/app/api/user/shelves/sync/route.ts b/src/app/api/user/shelves/sync/route.ts new file mode 100644 index 0000000..49a8f1d --- /dev/null +++ b/src/app/api/user/shelves/sync/route.ts @@ -0,0 +1,79 @@ +/** + * Component: Manual Shelf Sync API Route + * 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 { getJobQueueService } from '@/lib/services/job-queue.service'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.ShelvesSync'); + +const SyncSchema = z.object({ + shelfId: z.string().optional(), + shelfType: z.enum(['goodreads', 'hardcover']).optional(), +}); + +/** + * POST /api/user/shelves/sync + * Trigger a manual sync for all or a specific shelf belonging to the user. + */ +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 request.json().catch(() => ({})); + const { shelfId, shelfType } = SyncSchema.parse(body); + + // Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately + if (!shelfType || shelfType === 'goodreads') { + await prisma.goodreadsShelf.updateMany({ + where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) }, + data: { lastSyncAt: null }, + }); + } + + if (!shelfType || shelfType === 'hardcover') { + await prisma.hardcoverShelf.updateMany({ + where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) }, + data: { lastSyncAt: null }, + }); + } + + const jobQueue = getJobQueueService(); + + // Trigger sync job with userId filter + await jobQueue.addSyncShelvesJob( + undefined, + shelfId, + shelfType, + 0, // unlimited lookups for manual trigger + req.user.id + ); + + logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`); + + return NextResponse.json({ + success: true, + message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered' + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to trigger manual sync', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to trigger manual sync' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index 35e36b9..1127909 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -6,7 +6,11 @@ 'use client'; import React, { useState } from 'react'; -import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; +import { + useShelves, + GenericShelf, + useSyncShelves, +} from '@/lib/hooks/useShelves'; import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; import { AddShelfModal } from '@/components/ui/AddShelfModal'; @@ -37,6 +41,7 @@ export function ShelvesSection() { useDeleteGoodreadsShelf(); const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = useDeleteHardcoverShelf(); + const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves(); const { squareCovers } = usePreferences(); const [confirmDeleteId, setConfirmDeleteId] = useState(null); @@ -93,25 +98,48 @@ export function ShelvesSection() { {shelves.length > 0 && ( - + + + + {isSyncingAll ? 'Syncing...' : 'Resync All'} + + + )} @@ -268,6 +296,7 @@ function ShelfCard({ onManage, onBookClick, }: ShelfCardProps) { + const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves(); const displayBooks = shelf.books.slice(0, 6); const hasCovers = displayBooks.length > 0; const remainingCount = Math.max( @@ -372,6 +401,30 @@ function ShelfCard({ /> +