From 7f706e806fadd5974dee2cd03cd3e364db53108c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 10:28:52 -0500 Subject: [PATCH] Use hardcover-api service with pagination Replace the old hardcover sync usage with a new hardcover-api.service implementation that adds types, a reusable extractBooks helper, and paginated GraphQL queries (limit/offset) to fully fetch status and list books. Update API route import to use the new service. Fix ManageShelfModal to initialize rssUrl/listId as empty strings. Update tests to mock the new service and add encryption format helper mocking. --- src/app/api/user/hardcover-shelves/route.ts | 2 +- src/components/ui/ManageShelfModal.tsx | 4 +- src/lib/services/hardcover-api.service.ts | 207 +++++++++++------- tests/api/hardcover-shelves-id.routes.test.ts | 11 + tests/api/hardcover-shelves.routes.test.ts | 2 +- 5 files changed, 145 insertions(+), 81 deletions(-) diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 725870b..56feb35 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service'; +import { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { z } from 'zod'; diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx index 7dce0a6..5799907 100644 --- a/src/components/ui/ManageShelfModal.tsx +++ b/src/components/ui/ManageShelfModal.tsx @@ -19,8 +19,8 @@ interface ManageShelfModalProps { } export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { - const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : ''); - const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); + const [rssUrl, setRssUrl] = useState(''); + const [listId, setListId] = useState(''); const [apiToken, setApiToken] = useState(''); const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); diff --git a/src/lib/services/hardcover-api.service.ts b/src/lib/services/hardcover-api.service.ts index d8e4df8..a0441da 100644 --- a/src/lib/services/hardcover-api.service.ts +++ b/src/lib/services/hardcover-api.service.ts @@ -17,6 +17,48 @@ export interface HardcoverApiBook { coverUrl?: string; } +/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */ +interface HardcoverBookNode { + id?: number; + title?: string; + cached_image?: string | { url?: string }; + image?: { url?: string }; + contributions?: Array<{ author?: { name?: string } }>; +} + +/** Shape of a list object returned from the Hardcover GraphQL API */ +interface HardcoverListData { + name?: string; + list_books?: Array<{ book?: HardcoverBookNode }>; +} + +const PAGE_SIZE = 100; + +/** Extract HardcoverApiBook[] from an array of book-containing items */ +function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] { + const books: HardcoverApiBook[] = []; + for (const item of items) { + const book = item.book; + if (!book || !book.id) continue; + + const authorName = + book.contributions?.[0]?.author?.name || 'Unknown Author'; + const cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + return books; +} + /** * Fetch a Hardcover List using their GraphQL API. * This handles both 'status_id' user_books or 'list_id' list_books queries. @@ -32,9 +74,9 @@ export async function fetchHardcoverList( if (isStatus) { const statusId = parseInt(listIdStr.replace('status-', ''), 10); const query = ` - query GetStatusBooks($statusId: Int!) { + query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) { me { - user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) { + user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) { book { id title @@ -53,27 +95,6 @@ export async function fetchHardcoverList( } `; - const response = await axios.post( - HARDCOVER_API_URL, - { query, variables: { statusId } }, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - timeout: 30000, - }, - ); - - if (response.data?.errors) { - throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, - ); - } - - const userBooks = response.data?.data?.me?.[0]?.user_books || []; - let listName = 'Hardcover Status List'; - // Map status numbers to names const statusNames: Record = { 1: 'Want to Read', @@ -81,30 +102,41 @@ export async function fetchHardcoverList( 3: 'Read', 4: 'Did Not Finish', }; - listName = statusNames[statusId] || `Status ${statusId}`; + const listName = statusNames[statusId] || `Status ${statusId}`; - const books: HardcoverApiBook[] = []; - for (const item of userBooks) { - const book = item.book; - if (!book || !book.id) continue; + const allBooks: HardcoverApiBook[] = []; + let offset = 0; - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const cachedImg = book.cached_image; - const coverUrl = - (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || - book.image?.url || - undefined; + // Paginate until fewer results than PAGE_SIZE are returned + while (true) { + const response = await axios.post( + HARDCOVER_API_URL, + { query, variables: { statusId, limit: PAGE_SIZE, offset } }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); + if (response.data?.errors) { + throw new Error( + `Hardcover API Error: ${response.data.errors[0]?.message}`, + ); + } + + const userBooks: Array<{ book?: HardcoverBookNode }> = + response.data?.data?.me?.[0]?.user_books || []; + const pageBooks = extractBooks(userBooks); + allBooks.push(...pageBooks); + + if (userBooks.length < PAGE_SIZE) break; + offset += PAGE_SIZE; } - return { listName, books }; + return { listName, books: allBooks }; } else { // Custom list query // - URL with @username → query that user's lists by slug @@ -137,7 +169,7 @@ export async function fetchHardcoverList( const listBookFields = ` name - list_books(limit: 100, order_by: {id: desc}) { + list_books(limit: $limit, offset: $offset, order_by: {id: desc}) { book { id title cached_image image { url } contributions { author { name } } @@ -147,7 +179,7 @@ export async function fetchHardcoverList( // Numeric ID: globally unique, query the lists table directly const queryById = ` - query GetListBooks($listId: Int!) { + query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) { lists(where: {id: {_eq: $listId}}, limit: 1) { ${listBookFields} } @@ -156,7 +188,7 @@ export async function fetchHardcoverList( // Slug with username: query through the users table to scope to that user const queryByUserSlug = ` - query GetUserListBySlug($username: citext!, $slug: String!) { + query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) { users(where: {username: {_eq: $username}}, limit: 1) { lists(where: {slug: {_eq: $slug}}, limit: 1) { ${listBookFields} @@ -167,7 +199,7 @@ export async function fetchHardcoverList( // Bare slug (no username): scope to the authenticated user via `me` const queryByMySlug = ` - query GetMyListBySlug($slug: String!) { + query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) { me { lists(where: {slug: {_eq: $slug}}, limit: 1) { ${listBookFields} @@ -177,24 +209,25 @@ export async function fetchHardcoverList( `; let activeQuery: string; - let variables: Record; + let baseVariables: Record; if (isIntId) { activeQuery = queryById; - variables = { listId: parseInt(listIdStr, 10) }; + baseVariables = { listId: parseInt(listIdStr, 10) }; } else if (extractedUsername) { activeQuery = queryByUserSlug; - variables = { username: extractedUsername, slug: extractedSlug }; + baseVariables = { username: extractedUsername, slug: extractedSlug }; } else { activeQuery = queryByMySlug; - variables = { slug: extractedSlug }; + baseVariables = { slug: extractedSlug }; } - const response = await axios.post( + // First request to discover list metadata and first page of books + const firstResponse = await axios.post( HARDCOVER_API_URL, { query: activeQuery, - variables, + variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 }, }, { headers: { @@ -205,21 +238,21 @@ export async function fetchHardcoverList( }, ); - if (response.data?.errors) { + if (firstResponse.data?.errors) { throw new Error( - `Hardcover API Error: ${response.data.errors[0]?.message}`, + `Hardcover API Error: ${firstResponse.data.errors[0]?.message}`, ); } // Extract lists array from the response based on which query was used - let listsData: any[]; + let listsData: HardcoverListData[]; if (isIntId) { - listsData = response.data?.data?.lists || []; + listsData = firstResponse.data?.data?.lists || []; } else if (extractedUsername) { - const users = response.data?.data?.users || []; + const users = firstResponse.data?.data?.users || []; listsData = users[0]?.lists || []; } else { - listsData = response.data?.data?.me?.[0]?.lists || []; + listsData = firstResponse.data?.data?.me?.[0]?.lists || []; } if (listsData.length === 0) { @@ -235,29 +268,49 @@ export async function fetchHardcoverList( } const listName = listsData[0].name || 'Hardcover List'; - const listBooks = listsData[0].list_books || []; + const firstPageItems = listsData[0].list_books || []; + const allBooks = extractBooks(firstPageItems); - const books: HardcoverApiBook[] = []; - for (const item of listBooks) { - const book = item.book; - if (!book || !book.id) continue; + // Paginate if first page was full + if (firstPageItems.length >= PAGE_SIZE) { + let offset = PAGE_SIZE; - const authorName = - book.contributions?.[0]?.author?.name || 'Unknown Author'; - const cachedImg = book.cached_image; - const coverUrl = - (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || - book.image?.url || - undefined; + while (true) { + const pageResponse = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables: { ...baseVariables, limit: PAGE_SIZE, offset }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); - books.push({ - bookId: book.id.toString(), - title: book.title || 'Unknown Title', - author: authorName, - coverUrl, - }); + if (pageResponse.data?.errors) break; + + let pageListsData: HardcoverListData[]; + if (isIntId) { + pageListsData = pageResponse.data?.data?.lists || []; + } else if (extractedUsername) { + const users = pageResponse.data?.data?.users || []; + pageListsData = users[0]?.lists || []; + } else { + pageListsData = pageResponse.data?.data?.me?.[0]?.lists || []; + } + + const pageItems = pageListsData[0]?.list_books || []; + allBooks.push(...extractBooks(pageItems)); + + if (pageItems.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } } - return { listName, books }; + return { listName, books: allBooks }; } } diff --git a/tests/api/hardcover-shelves-id.routes.test.ts b/tests/api/hardcover-shelves-id.routes.test.ts index 338ce2d..7d084ea 100644 --- a/tests/api/hardcover-shelves-id.routes.test.ts +++ b/tests/api/hardcover-shelves-id.routes.test.ts @@ -16,8 +16,11 @@ const jobQueueMock = vi.hoisted(() => ({ const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((s: string) => `enc:${s}`), decrypt: vi.fn((s: string) => s.replace('enc:', '')), + isEncryptedFormat: vi.fn((s: string) => s.startsWith('enc:')), })); +const fetchHardcoverListMock = vi.hoisted(() => vi.fn()); + vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, })); @@ -34,6 +37,10 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); +vi.mock('@/lib/services/hardcover-api.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + const SHELF = { id: 'hc-shelf-1', userId: 'user-1', @@ -106,6 +113,10 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => { vi.clearAllMocks(); authRequest = { user: { id: 'user-1', role: 'user' } }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + encryptionMock.isEncryptedFormat.mockImplementation((s: string) => s.startsWith('enc:')); + encryptionMock.encrypt.mockImplementation((s: string) => `enc:${s}`); + encryptionMock.decrypt.mockImplementation((s: string) => s.replace('enc:', '')); + fetchHardcoverListMock.mockResolvedValue({ listName: 'Test List', books: [] }); }); it('returns 404 when list does not exist', async () => { diff --git a/tests/api/hardcover-shelves.routes.test.ts b/tests/api/hardcover-shelves.routes.test.ts index fae8242..176bf1e 100644 --- a/tests/api/hardcover-shelves.routes.test.ts +++ b/tests/api/hardcover-shelves.routes.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); -vi.mock('@/lib/services/hardcover-sync.service', () => ({ +vi.mock('@/lib/services/hardcover-api.service', () => ({ fetchHardcoverList: fetchHardcoverListMock, }));