mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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 {
|
||||
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),
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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() !== '') {
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
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<string | null>(null);
|
||||
@@ -93,25 +98,48 @@ export function ShelvesSection() {
|
||||
</div>
|
||||
|
||||
{shelves.length > 0 && (
|
||||
<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}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => syncShelves()}
|
||||
disabled={isSyncingAll}
|
||||
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"
|
||||
title="Resync all shelves"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
<svg
|
||||
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</svg>
|
||||
</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
|
||||
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"
|
||||
|
||||
@@ -51,6 +51,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
||||
await updateHardcover(shelf.id, {
|
||||
listId: listId.trim(),
|
||||
apiToken: apiToken.trim() || undefined,
|
||||
forceSync: true,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useUpdateHardcoverShelf() {
|
||||
|
||||
const updateShelf = async (
|
||||
shelfId: string,
|
||||
updates: { listId?: string; apiToken?: string },
|
||||
updates: { listId?: string; apiToken?: string; forceSync?: boolean },
|
||||
) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Component: Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ShelfBook } from './useGoodreadsShelves';
|
||||
@@ -38,3 +38,52 @@ export function useShelves() {
|
||||
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;
|
||||
/** The type of shelf, if shelfId is specified */
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
/** If set, only process shelves for this user */
|
||||
userId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
@@ -22,7 +24,7 @@ export interface SyncShelvesPayload {
|
||||
export async function processSyncShelves(
|
||||
payload: SyncShelvesPayload,
|
||||
): Promise<any> {
|
||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
||||
const { jobId, shelfId, shelfType, userId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||
|
||||
const stats = {
|
||||
@@ -48,6 +50,7 @@ export async function processSyncShelves(
|
||||
await import('../services/goodreads-sync.service');
|
||||
const grStats = await processGoodreadsShelves(logger, {
|
||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||
userId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
@@ -70,6 +73,7 @@ export async function processSyncShelves(
|
||||
await import('../services/hardcover-sync.service');
|
||||
const hcStats = await processHardcoverShelves(logger, {
|
||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||
userId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import axios from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
ShelfBook,
|
||||
@@ -118,7 +119,10 @@ export async function processGoodreadsShelves(
|
||||
const stats = createEmptyStats();
|
||||
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({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||
@@ -38,8 +39,10 @@ export async function processHardcoverShelves(
|
||||
const log = jobLogger || logger;
|
||||
const stats = createEmptyStats();
|
||||
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({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface SyncShelvesPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
shelfId?: string;
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
userId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
@@ -770,7 +771,13 @@ export class JobQueueService {
|
||||
/**
|
||||
* 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(
|
||||
'sync_reading_shelves',
|
||||
{
|
||||
@@ -778,6 +785,7 @@ export class JobQueueService {
|
||||
shelfId,
|
||||
shelfType,
|
||||
maxLookupsPerShelf,
|
||||
userId,
|
||||
} as SyncShelvesPayload,
|
||||
{
|
||||
priority: 7,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ShelfSyncStats {
|
||||
/** Common sync options */
|
||||
export interface ShelfSyncOptions {
|
||||
shelfId?: string;
|
||||
userId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user