mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge pull request #136 from brombomb/fix-shelf-sync
Add Shelf Syncing button
This commit is contained in:
@@ -91,7 +91,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate list sync', {
|
logger.error('Failed to trigger immediate list sync', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
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})`);
|
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -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() !== '') {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* Component: Shelves Hook
|
* Component: Shelves Hook
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import useSWR from 'swr';
|
import { useState } from 'react';
|
||||||
|
import useSWR, { mutate } 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 +38,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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import {
|
import {
|
||||||
ShelfBook,
|
ShelfBook,
|
||||||
@@ -118,7 +119,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: Prisma.GoodreadsShelfWhereInput = {};
|
||||||
|
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 } } },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||||
@@ -38,8 +39,10 @@ export async function processHardcoverShelves(
|
|||||||
const log = jobLogger || logger;
|
const log = jobLogger || logger;
|
||||||
const stats = createEmptyStats();
|
const stats = createEmptyStats();
|
||||||
const maxLookups = resolveMaxLookups(options);
|
const maxLookups = resolveMaxLookups(options);
|
||||||
|
const whereClause: Prisma.HardcoverShelfWhereInput = {};
|
||||||
|
if (options.shelfId) whereClause.id = options.shelfId;
|
||||||
|
if (options.userId) whereClause.userId = options.userId;
|
||||||
|
|
||||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
|
||||||
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 } } },
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +771,13 @@ export class JobQueueService {
|
|||||||
/**
|
/**
|
||||||
* Add sync reading shelves job
|
* Add sync reading shelves job
|
||||||
*/
|
*/
|
||||||
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
|
async addSyncShelvesJob(
|
||||||
|
scheduledJobId?: string,
|
||||||
|
shelfId?: string,
|
||||||
|
shelfType?: 'goodreads' | 'hardcover',
|
||||||
|
maxLookupsPerShelf?: number,
|
||||||
|
userId?: string
|
||||||
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'sync_reading_shelves',
|
'sync_reading_shelves',
|
||||||
{
|
{
|
||||||
@@ -778,6 +785,7 @@ export class JobQueueService {
|
|||||||
shelfId,
|
shelfId,
|
||||||
shelfType,
|
shelfType,
|
||||||
maxLookupsPerShelf,
|
maxLookupsPerShelf,
|
||||||
|
userId,
|
||||||
} as SyncShelvesPayload,
|
} as SyncShelvesPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,40 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
|
|||||||
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('triggers a sync when forceSync is true, even if no fields changed', async () => {
|
||||||
|
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, forceSync: true }),
|
||||||
|
} 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({
|
||||||
|
lastSyncAt: null,
|
||||||
|
bookCount: null,
|
||||||
|
coverUrls: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
SHELF.id,
|
||||||
|
'hardcover',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates listId, clears metadata, and triggers a sync job', async () => {
|
it('updates listId, clears metadata, and triggers a sync job', async () => {
|
||||||
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
|
||||||
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
|
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shelves Sync 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('POST /api/user/shelves/sync', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authRequest = { user: { id: 'user-1', role: 'user' } };
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a manual sync for all shelves when no parameters provided', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({}) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
|
||||||
|
// Both tables should have updateMany called to clear lastSyncAt
|
||||||
|
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
expect(prismaMock.hardcoverShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined, // scheduledJobId
|
||||||
|
undefined, // shelfId
|
||||||
|
undefined, // shelfType
|
||||||
|
0, // maxLookupsPerShelf (unlimited for manual)
|
||||||
|
'user-1' // userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a manual sync for a specific shelf', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({ shelfId: 'shelf-123', shelfType: 'goodreads' }) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
|
||||||
|
// Only goodreads should be updated since shelfType is specified
|
||||||
|
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', id: 'shelf-123' },
|
||||||
|
data: { lastSyncAt: null },
|
||||||
|
});
|
||||||
|
expect(prismaMock.hardcoverShelf.updateMany).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined, // scheduledJobId
|
||||||
|
'shelf-123', // shelfId
|
||||||
|
'goodreads', // shelfType
|
||||||
|
0, // maxLookupsPerShelf
|
||||||
|
'user-1' // userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid body gracefully', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockRejectedValue(new Error('Invalid JSON')) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
// Since body parsing fails gracefully with catching () => ({}), it treats it as sync all
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
'user-1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates wrong shelfType', async () => {
|
||||||
|
const { POST } = await import('@/app/api/user/shelves/sync/route');
|
||||||
|
const response = await POST(
|
||||||
|
{ json: vi.fn().mockResolvedValue({ shelfType: 'invalid-type' }) } as any,
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toBe('ValidationError');
|
||||||
|
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user