From a564fefd7c17245c24da09acafcb7e85f8857442 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Thu, 5 Mar 2026 22:24:42 -0700 Subject: [PATCH] Add refresh shelf capability --- .../api/user/hardcover-shelves/[id]/route.ts | 5 +- src/app/api/user/shelves/sync/route.ts | 63 +++++++++++++ src/components/profile/ShelvesSection.tsx | 91 +++++++++++++++---- src/components/ui/ManageShelfModal.tsx | 1 + src/lib/hooks/useHardcoverShelves.ts | 2 +- src/lib/hooks/useShelves.ts | 55 ++++++++++- src/lib/processors/sync-shelves.processor.ts | 6 +- src/lib/services/goodreads-sync.service.ts | 5 +- src/lib/services/hardcover-sync.service.ts | 5 +- src/lib/services/job-queue.service.ts | 1 + src/lib/services/shelf-sync-core.service.ts | 1 + 11 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 src/app/api/user/shelves/sync/route.ts 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/shelves/sync/route.ts b/src/app/api/user/shelves/sync/route.ts new file mode 100644 index 0000000..0ae9459 --- /dev/null +++ b/src/app/api/user/shelves/sync/route.ts @@ -0,0 +1,63 @@ +/** + * 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 { 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); + + 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({ /> +