diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7234838..9297990 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -527,9 +527,10 @@ model GoodreadsShelf { rssUrl String @map("rss_url") @db.Text lastSyncAt DateTime? @map("last_sync_at") bookCount Int? @map("book_count") - coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs + autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -577,9 +578,10 @@ model HardcoverShelf { apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api lastSyncAt DateTime? @map("last_sync_at") bookCount Int? @map("book_count") - coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs + autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts index 7f5a226..6fd3a28 100644 --- a/src/app/api/user/goodreads-shelves/[id]/route.ts +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -13,7 +13,8 @@ import { z } from 'zod'; const logger = RMABLogger.create('API.GoodreadsShelves'); const UpdateGoodreadsSchema = z.object({ - rssUrl: z.string().url('Must be a valid URL'), + rssUrl: z.string().url('Must be a valid URL').optional(), + autoRequest: z.boolean().optional(), }); /** @@ -81,21 +82,37 @@ export async function PATCH( } const body = await request.json(); - const { rssUrl } = UpdateGoodreadsSchema.parse(body); + const { rssUrl, autoRequest } = UpdateGoodreadsSchema.parse(body); + + const updateData: Record = {}; + let needsResync = false; + + if (rssUrl !== undefined) { + updateData.rssUrl = rssUrl; + updateData.lastSyncAt = null; + updateData.bookCount = null; + updateData.coverUrls = null; + needsResync = true; + } + + if (autoRequest !== undefined) { + updateData.autoRequest = autoRequest; + } - // Force re-fetch by clearing metadata const updated = await prisma.goodreadsShelf.update({ where: { id }, - data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null }, + data: updateData, }); - try { - const jobQueue = getJobQueueService(); - await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); - } catch (error) { - logger.error('Failed to trigger immediate list sync', { - error: error instanceof Error ? error.message : String(error), - }); + if (needsResync) { + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } } return NextResponse.json({ success: true, shelf: updated }); diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 8626fc0..7fdd268 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -20,6 +20,7 @@ const AddShelfSchema = z.object({ (url) => GOODREADS_RSS_PATTERN.test(url), { message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' } ), + autoRequest: z.boolean().optional().default(true), }); /** @@ -66,6 +67,7 @@ export async function GET(request: NextRequest) { lastSyncAt: shelf.lastSyncAt, createdAt: shelf.createdAt, bookCount: shelf.bookCount ?? null, + autoRequest: shelf.autoRequest, books, }; }); @@ -90,7 +92,7 @@ export async function POST(request: NextRequest) { } const body = await req.json(); - const { rssUrl } = AddShelfSchema.parse(body); + const { rssUrl, autoRequest } = AddShelfSchema.parse(body); // Check for duplicate const existing = await prisma.goodreadsShelf.findUnique({ @@ -132,6 +134,7 @@ export async function POST(request: NextRequest) { name: shelfName, rssUrl, bookCount, + autoRequest, coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, }, }); @@ -154,6 +157,7 @@ export async function POST(request: NextRequest) { lastSyncAt: shelf.lastSyncAt, createdAt: shelf.createdAt, bookCount: shelf.bookCount, + autoRequest: shelf.autoRequest, books: initialBooks, }, bookCount, diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index b0a7916..92531d6 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(), + autoRequest: z.boolean().optional(), }); /** @@ -89,9 +90,13 @@ export async function PATCH( } const body = await request.json(); - const { listId, apiToken } = UpdateHardcoverSchema.parse(body); + const { listId, apiToken, autoRequest } = UpdateHardcoverSchema.parse(body); - const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {}; + const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {}; + + if (autoRequest !== undefined) { + updateData.autoRequest = autoRequest; + } let needsResync = false; let cleanedToken: string | undefined; diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 56feb35..e275499 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -18,6 +18,7 @@ const logger = RMABLogger.create('API.HardcoverShelves'); const AddShelfSchema = z.object({ listId: z.string().min(1, { message: 'List ID is required' }), apiToken: z.string().min(1, { message: 'API Token is required' }), + autoRequest: z.boolean().optional().default(true), }); /** @@ -46,6 +47,7 @@ export async function GET(request: NextRequest) { lastSyncAt: shelf.lastSyncAt, createdAt: shelf.createdAt, bookCount: shelf.bookCount ?? null, + autoRequest: shelf.autoRequest, books, }; }); @@ -75,7 +77,9 @@ export async function POST(request: NextRequest) { } const body = await req.json(); - let { listId, apiToken } = AddShelfSchema.parse(body); + const parsed = AddShelfSchema.parse(body); + let { listId, apiToken } = parsed; + const { autoRequest } = parsed; // Clean up token in case user pasted "Bearer " prefix apiToken = apiToken.trim(); @@ -139,6 +143,7 @@ export async function POST(request: NextRequest) { name: listName, listId, apiToken: encryptedToken, + autoRequest, bookCount, coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, @@ -168,6 +173,7 @@ export async function POST(request: NextRequest) { lastSyncAt: shelf.lastSyncAt, createdAt: shelf.createdAt, bookCount: shelf.bookCount, + autoRequest: shelf.autoRequest, books: initialBooks, }, bookCount, diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts index f017a78..c32b5f7 100644 --- a/src/app/api/user/shelves/route.ts +++ b/src/app/api/user/shelves/route.ts @@ -42,6 +42,7 @@ export async function GET(request: NextRequest) { lastSyncAt: s.lastSyncAt, createdAt: s.createdAt, bookCount: s.bookCount ?? null, + autoRequest: s.autoRequest, books: processBooks(s.coverUrls), })), ...hardcover.map((s) => ({ @@ -52,6 +53,7 @@ export async function GET(request: NextRequest) { lastSyncAt: s.lastSyncAt, createdAt: s.createdAt, bookCount: s.bookCount ?? null, + autoRequest: s.autoRequest, books: processBooks(s.coverUrls), })), ].sort( diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index 35e36b9..07e5a33 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -7,8 +7,8 @@ import React, { useState } from 'react'; import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; -import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; -import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { usePreferences } from '@/contexts/PreferencesContext'; @@ -37,6 +37,8 @@ export function ShelvesSection() { useDeleteGoodreadsShelf(); const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = useDeleteHardcoverShelf(); + const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover } = useUpdateHardcoverShelf(); const { squareCovers } = usePreferences(); const [confirmDeleteId, setConfirmDeleteId] = useState(null); @@ -57,6 +59,18 @@ export function ShelvesSection() { } }; + const handleToggleAutoRequest = async (shelf: GenericShelf) => { + try { + if (shelf.type === 'goodreads') { + await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest }); + } else { + await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest }); + } + } catch { + // Error handled by hook + } + }; + const isDeleting = isDeletingGoodreads || isDeletingHardcover; return ( @@ -131,6 +145,7 @@ export function ShelvesSection() { onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} onManage={() => setManageShelf(shelf)} + onToggleAutoRequest={() => handleToggleAutoRequest(shelf)} onBookClick={(asin) => setSelectedAsin(asin)} /> ))} @@ -254,6 +269,7 @@ interface ShelfCardProps { onConfirmDelete: () => void; onCancelDelete: () => void; onManage: () => void; + onToggleAutoRequest: () => void; onBookClick: (asin: string) => void; } @@ -266,6 +282,7 @@ function ShelfCard({ onConfirmDelete, onCancelDelete, onManage, + onToggleAutoRequest, onBookClick, }: ShelfCardProps) { const displayBooks = shelf.books.slice(0, 6); @@ -292,7 +309,12 @@ function ShelfCard({ ); return ( -
+
{/* Top: Shelf info + actions */}
-

+

{shelf.name} {providerIcon}

@@ -310,6 +337,14 @@ function ShelfCard({ {shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'} )} + {!shelf.autoRequest && ( + + + + + Paused + + )} {isSyncing ? ( <> @@ -352,6 +387,27 @@ function ShelfCard({
) : (
+
); } diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx index 537235f..9f2ae48 100644 --- a/src/components/ui/AddShelfModal.tsx +++ b/src/components/ui/AddShelfModal.tsx @@ -32,6 +32,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { const [statusId, setStatusId] = useState('1'); const [customListId, setCustomListId] = useState(''); + // Shared State + const [autoRequest, setAutoRequest] = useState(true); const [validationError, setValidationError] = useState(''); const [success, setSuccess] = useState(false); const [successMessage, setSuccessMessage] = useState(''); @@ -72,12 +74,12 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { try { if (provider === 'goodreads') { - const shelf = await addGoodreads(rssUrl); + const shelf = await addGoodreads(rssUrl, autoRequest); setSuccessMessage(`Added shelf "${shelf.name}" successfully!`); setRssUrl(''); } else { const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim(); - const shelf = await addHardcover(apiToken.trim(), finalId); + const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest); setSuccessMessage(`Added list "${shelf.name}" successfully!`); setApiToken(''); setCustomListId(''); @@ -98,6 +100,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { setRssUrl(''); setApiToken(''); setCustomListId(''); + setAutoRequest(true); setValidationError(''); setSuccess(false); setSuccessMessage(''); @@ -215,6 +218,32 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { /> )} + {/* Auto-Request Toggle */} + +