Add refresh shelf capability

This commit is contained in:
Rob Walsh
2026-03-05 22:24:42 -07:00
parent 01b59fae9d
commit a564fefd7c
11 changed files with 206 additions and 29 deletions
@@ -17,6 +17,7 @@ const logger = RMABLogger.create('API.HardcoverShelves');
const UpdateHardcoverSchema = z.object({ const UpdateHardcoverSchema = z.object({
listId: z.string().min(1, 'List ID is required').optional(), listId: z.string().min(1, 'List ID is required').optional(),
apiToken: z.string().optional(), apiToken: z.string().optional(),
forceSync: z.boolean().optional(),
}); });
/** /**
@@ -89,10 +90,10 @@ export async function PATCH(
} }
const body = await request.json(); 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 } = {}; const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
let needsResync = false; let needsResync = !!forceSync;
let cleanedToken: string | undefined; let cleanedToken: string | undefined;
if (apiToken && apiToken.trim() !== '') { if (apiToken && apiToken.trim() !== '') {
+63
View File
@@ -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 },
);
}
});
}
+72 -19
View File
@@ -6,7 +6,11 @@
'use client'; 'use client';
import React, { useState } from 'react'; 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 { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { AddShelfModal } from '@/components/ui/AddShelfModal';
@@ -37,6 +41,7 @@ export function ShelvesSection() {
useDeleteGoodreadsShelf(); useDeleteGoodreadsShelf();
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
useDeleteHardcoverShelf(); useDeleteHardcoverShelf();
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
@@ -93,25 +98,48 @@ export function ShelvesSection() {
</div> </div>
{shelves.length > 0 && ( {shelves.length > 0 && (
<button <div className="flex items-center gap-3">
onClick={() => setShowAddShelf(true)} <button
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm" onClick={() => syncShelves()}
> disabled={isSyncingAll}
<svg className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
className="w-4 h-4" title="Resync all shelves"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
> >
<path <svg
strokeLinecap="round" className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
strokeLinejoin="round" fill="none"
d="M12 4.5v15m7.5-7.5h-15" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> strokeWidth={2}
Add Shelf >
</button> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
{isSyncingAll ? 'Syncing...' : 'Resync All'}
</button>
<button
onClick={() => setShowAddShelf(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Add Shelf
</button>
</div>
)} )}
</div> </div>
@@ -268,6 +296,7 @@ function ShelfCard({
onManage, onManage,
onBookClick, onBookClick,
}: ShelfCardProps) { }: ShelfCardProps) {
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
const displayBooks = shelf.books.slice(0, 6); const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0; const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max( const remainingCount = Math.max(
@@ -372,6 +401,30 @@ function ShelfCard({
/> />
</svg> </svg>
</button> </button>
<button
onClick={() => syncShelves(shelf.id, shelf.type)}
disabled={isManualSyncing}
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
title="Resync shelf"
aria-label="Resync shelf"
>
<svg
className={cn(
'w-[18px] h-[18px]',
isManualSyncing && 'animate-spin',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button <button
onClick={onConfirmDelete} onClick={onConfirmDelete}
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none" className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
+1
View File
@@ -51,6 +51,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
await updateHardcover(shelf.id, { await updateHardcover(shelf.id, {
listId: listId.trim(), listId: listId.trim(),
apiToken: apiToken.trim() || undefined, apiToken: apiToken.trim() || undefined,
forceSync: true,
}); });
} }
onClose(); onClose();
+1 -1
View File
@@ -41,7 +41,7 @@ export function useUpdateHardcoverShelf() {
const updateShelf = async ( const updateShelf = async (
shelfId: string, shelfId: string,
updates: { listId?: string; apiToken?: string }, updates: { listId?: string; apiToken?: string; forceSync?: boolean },
) => { ) => {
return updateGeneric(shelfId, updates); return updateGeneric(shelfId, updates);
}; };
+51 -4
View File
@@ -2,10 +2,8 @@
* Component: Shelves Hook * Component: Shelves Hook
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
*/ */
import { useState } from 'react';
'use client'; import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { ShelfBook } from './useGoodreadsShelves'; import { ShelfBook } from './useGoodreadsShelves';
@@ -38,3 +36,52 @@ export function useShelves() {
error, error,
}; };
} }
export function useSyncShelves() {
const { accessToken } = useAuth();
const [isSyncing, setIsSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const syncShelves = async (
shelfId?: string,
shelfType?: 'goodreads' | 'hardcover',
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsSyncing(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/shelves/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shelfId, shelfType }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to trigger sync');
}
// Invalidate both the provider-specific endpoints and the combined endpoint
mutate(
(key) =>
typeof key === 'string' &&
(key.includes('/api/user/shelves') ||
key.includes('/api/user/goodreads-shelves') ||
key.includes('/api/user/hardcover-shelves')),
);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsSyncing(false);
}
};
return { syncShelves, isSyncing, error };
}
+5 -1
View File
@@ -15,6 +15,8 @@ export interface SyncShelvesPayload {
shelfId?: string; shelfId?: string;
/** The type of shelf, if shelfId is specified */ /** The type of shelf, if shelfId is specified */
shelfType?: 'goodreads' | 'hardcover'; shelfType?: 'goodreads' | 'hardcover';
/** If set, only process shelves for this user */
userId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */ /** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number; maxLookupsPerShelf?: number;
} }
@@ -22,7 +24,7 @@ export interface SyncShelvesPayload {
export async function processSyncShelves( export async function processSyncShelves(
payload: SyncShelvesPayload, payload: SyncShelvesPayload,
): Promise<any> { ): Promise<any> {
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload; const { jobId, shelfId, shelfType, userId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncShelves'); const logger = RMABLogger.forJob(jobId, 'SyncShelves');
const stats = { const stats = {
@@ -48,6 +50,7 @@ export async function processSyncShelves(
await import('../services/goodreads-sync.service'); await import('../services/goodreads-sync.service');
const grStats = await processGoodreadsShelves(logger, { const grStats = await processGoodreadsShelves(logger, {
shelfId: shelfType === 'goodreads' ? shelfId : undefined, shelfId: shelfType === 'goodreads' ? shelfId : undefined,
userId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
}); });
@@ -70,6 +73,7 @@ export async function processSyncShelves(
await import('../services/hardcover-sync.service'); await import('../services/hardcover-sync.service');
const hcStats = await processHardcoverShelves(logger, { const hcStats = await processHardcoverShelves(logger, {
shelfId: shelfType === 'hardcover' ? shelfId : undefined, shelfId: shelfType === 'hardcover' ? shelfId : undefined,
userId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
}); });
+4 -1
View File
@@ -118,7 +118,10 @@ export async function processGoodreadsShelves(
const stats = createEmptyStats(); const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options); const maxLookups = resolveMaxLookups(options);
const whereClause = options.shelfId ? { id: options.shelfId } : {}; const whereClause: any = {};
if (options.shelfId) whereClause.id = options.shelfId;
if (options.userId) whereClause.userId = options.userId;
const shelves = await prisma.goodreadsShelf.findMany({ const shelves = await prisma.goodreadsShelf.findMany({
where: whereClause, where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } }, include: { user: { select: { id: true, plexUsername: true } } },
+4 -1
View File
@@ -39,7 +39,10 @@ export async function processHardcoverShelves(
const stats = createEmptyStats(); const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options); const maxLookups = resolveMaxLookups(options);
const whereClause = options.shelfId ? { id: options.shelfId } : {}; const whereClause: any = {};
if (options.shelfId) whereClause.id = options.shelfId;
if (options.userId) whereClause.userId = options.userId;
const shelves = await prisma.hardcoverShelf.findMany({ const shelves = await prisma.hardcoverShelf.findMany({
where: whereClause, where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } }, include: { user: { select: { id: true, plexUsername: true } } },
+1
View File
@@ -112,6 +112,7 @@ export interface SyncShelvesPayload extends JobPayload {
scheduledJobId?: string; scheduledJobId?: string;
shelfId?: string; shelfId?: string;
shelfType?: 'goodreads' | 'hardcover'; shelfType?: 'goodreads' | 'hardcover';
userId?: string;
maxLookupsPerShelf?: number; maxLookupsPerShelf?: number;
} }
@@ -39,6 +39,7 @@ export interface ShelfSyncStats {
/** Common sync options */ /** Common sync options */
export interface ShelfSyncOptions { export interface ShelfSyncOptions {
shelfId?: string; shelfId?: string;
userId?: string;
maxLookupsPerShelf?: number; maxLookupsPerShelf?: number;
} }