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.
This commit is contained in:
kikootwo
2026-03-04 10:28:52 -05:00
parent 338331d006
commit 7f706e806f
5 changed files with 145 additions and 81 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; 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 { getJobQueueService } from '@/lib/services/job-queue.service';
import { getEncryptionService } from '@/lib/services/encryption.service'; import { getEncryptionService } from '@/lib/services/encryption.service';
import { z } from 'zod'; import { z } from 'zod';
+2 -2
View File
@@ -19,8 +19,8 @@ interface ManageShelfModalProps {
} }
export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) {
const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : ''); const [rssUrl, setRssUrl] = useState('');
const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); const [listId, setListId] = useState('');
const [apiToken, setApiToken] = useState(''); const [apiToken, setApiToken] = useState('');
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
+152 -99
View File
@@ -17,74 +17,27 @@ export interface HardcoverApiBook {
coverUrl?: string; coverUrl?: string;
} }
/** /** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
* Fetch a Hardcover List using their GraphQL API. interface HardcoverBookNode {
* This handles both 'status_id' user_books or 'list_id' list_books queries. id?: number;
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id. title?: string;
*/ cached_image?: string | { url?: string };
export async function fetchHardcoverList( image?: { url?: string };
apiToken: string, contributions?: Array<{ author?: { name?: string } }>;
listIdStr: string,
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
// Check if it's a status list
const isStatus = listIdStr.startsWith('status-');
if (isStatus) {
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
const query = `
query GetStatusBooks($statusId: Int!) {
me {
user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) {
book {
id
title
contributions {
author {
name
}
}
cached_image
image {
url
}
}
}
}
}
`;
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 || []; /** Shape of a list object returned from the Hardcover GraphQL API */
let listName = 'Hardcover Status List'; interface HardcoverListData {
name?: string;
list_books?: Array<{ book?: HardcoverBookNode }>;
}
// Map status numbers to names const PAGE_SIZE = 100;
const statusNames: Record<number, string> = {
1: 'Want to Read',
2: 'Currently Reading',
3: 'Read',
4: 'Did Not Finish',
};
listName = statusNames[statusId] || `Status ${statusId}`;
/** Extract HardcoverApiBook[] from an array of book-containing items */
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
const books: HardcoverApiBook[] = []; const books: HardcoverApiBook[] = [];
for (const item of userBooks) { for (const item of items) {
const book = item.book; const book = item.book;
if (!book || !book.id) continue; if (!book || !book.id) continue;
@@ -103,8 +56,87 @@ export async function fetchHardcoverList(
coverUrl, coverUrl,
}); });
} }
return books;
}
return { listName, books }; /**
* Fetch a Hardcover List using their GraphQL API.
* This handles both 'status_id' user_books or 'list_id' list_books queries.
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
*/
export async function fetchHardcoverList(
apiToken: string,
listIdStr: string,
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
// Check if it's a status list
const isStatus = listIdStr.startsWith('status-');
if (isStatus) {
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
const query = `
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
me {
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
book {
id
title
contributions {
author {
name
}
}
cached_image
image {
url
}
}
}
}
}
`;
// Map status numbers to names
const statusNames: Record<number, string> = {
1: 'Want to Read',
2: 'Currently Reading',
3: 'Read',
4: 'Did Not Finish',
};
const listName = statusNames[statusId] || `Status ${statusId}`;
const allBooks: HardcoverApiBook[] = [];
let offset = 0;
// 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,
},
);
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: allBooks };
} else { } else {
// Custom list query // Custom list query
// - URL with @username → query that user's lists by slug // - URL with @username → query that user's lists by slug
@@ -137,7 +169,7 @@ export async function fetchHardcoverList(
const listBookFields = ` const listBookFields = `
name name
list_books(limit: 100, order_by: {id: desc}) { list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
book { book {
id title cached_image image { url } id title cached_image image { url }
contributions { author { name } } contributions { author { name } }
@@ -147,7 +179,7 @@ export async function fetchHardcoverList(
// Numeric ID: globally unique, query the lists table directly // Numeric ID: globally unique, query the lists table directly
const queryById = ` const queryById = `
query GetListBooks($listId: Int!) { query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
lists(where: {id: {_eq: $listId}}, limit: 1) { lists(where: {id: {_eq: $listId}}, limit: 1) {
${listBookFields} ${listBookFields}
} }
@@ -156,7 +188,7 @@ export async function fetchHardcoverList(
// Slug with username: query through the users table to scope to that user // Slug with username: query through the users table to scope to that user
const queryByUserSlug = ` 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) { users(where: {username: {_eq: $username}}, limit: 1) {
lists(where: {slug: {_eq: $slug}}, limit: 1) { lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields} ${listBookFields}
@@ -167,7 +199,7 @@ export async function fetchHardcoverList(
// Bare slug (no username): scope to the authenticated user via `me` // Bare slug (no username): scope to the authenticated user via `me`
const queryByMySlug = ` const queryByMySlug = `
query GetMyListBySlug($slug: String!) { query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
me { me {
lists(where: {slug: {_eq: $slug}}, limit: 1) { lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields} ${listBookFields}
@@ -177,24 +209,25 @@ export async function fetchHardcoverList(
`; `;
let activeQuery: string; let activeQuery: string;
let variables: Record<string, unknown>; let baseVariables: Record<string, unknown>;
if (isIntId) { if (isIntId) {
activeQuery = queryById; activeQuery = queryById;
variables = { listId: parseInt(listIdStr, 10) }; baseVariables = { listId: parseInt(listIdStr, 10) };
} else if (extractedUsername) { } else if (extractedUsername) {
activeQuery = queryByUserSlug; activeQuery = queryByUserSlug;
variables = { username: extractedUsername, slug: extractedSlug }; baseVariables = { username: extractedUsername, slug: extractedSlug };
} else { } else {
activeQuery = queryByMySlug; 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, HARDCOVER_API_URL,
{ {
query: activeQuery, query: activeQuery,
variables, variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
}, },
{ {
headers: { headers: {
@@ -205,21 +238,21 @@ export async function fetchHardcoverList(
}, },
); );
if (response.data?.errors) { if (firstResponse.data?.errors) {
throw new Error( 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 // Extract lists array from the response based on which query was used
let listsData: any[]; let listsData: HardcoverListData[];
if (isIntId) { if (isIntId) {
listsData = response.data?.data?.lists || []; listsData = firstResponse.data?.data?.lists || [];
} else if (extractedUsername) { } else if (extractedUsername) {
const users = response.data?.data?.users || []; const users = firstResponse.data?.data?.users || [];
listsData = users[0]?.lists || []; listsData = users[0]?.lists || [];
} else { } else {
listsData = response.data?.data?.me?.[0]?.lists || []; listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
} }
if (listsData.length === 0) { if (listsData.length === 0) {
@@ -235,29 +268,49 @@ export async function fetchHardcoverList(
} }
const listName = listsData[0].name || 'Hardcover List'; 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[] = []; // Paginate if first page was full
for (const item of listBooks) { if (firstPageItems.length >= PAGE_SIZE) {
const book = item.book; let offset = PAGE_SIZE;
if (!book || !book.id) continue;
const authorName = while (true) {
book.contributions?.[0]?.author?.name || 'Unknown Author'; const pageResponse = await axios.post(
const cachedImg = book.cached_image; HARDCOVER_API_URL,
const coverUrl = {
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || query: activeQuery,
book.image?.url || variables: { ...baseVariables, limit: PAGE_SIZE, offset },
undefined; },
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
books.push({ if (pageResponse.data?.errors) break;
bookId: book.id.toString(),
title: book.title || 'Unknown Title', let pageListsData: HardcoverListData[];
author: authorName, if (isIntId) {
coverUrl, 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 || [];
} }
return { listName, books }; const pageItems = pageListsData[0]?.list_books || [];
allBooks.push(...extractBooks(pageItems));
if (pageItems.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
}
return { listName, books: allBooks };
} }
} }
@@ -16,8 +16,11 @@ const jobQueueMock = vi.hoisted(() => ({
const encryptionMock = vi.hoisted(() => ({ const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((s: string) => `enc:${s}`), encrypt: vi.fn((s: string) => `enc:${s}`),
decrypt: vi.fn((s: string) => s.replace('enc:', '')), 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', () => ({ vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock, requireAuth: requireAuthMock,
})); }));
@@ -34,6 +37,10 @@ vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock, getEncryptionService: () => encryptionMock,
})); }));
vi.mock('@/lib/services/hardcover-api.service', () => ({
fetchHardcoverList: fetchHardcoverListMock,
}));
const SHELF = { const SHELF = {
id: 'hc-shelf-1', id: 'hc-shelf-1',
userId: 'user-1', userId: 'user-1',
@@ -106,6 +113,10 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
vi.clearAllMocks(); vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } }; authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); 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 () => { it('returns 404 when list does not exist', async () => {
+1 -1
View File
@@ -35,7 +35,7 @@ vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock, getEncryptionService: () => encryptionMock,
})); }));
vi.mock('@/lib/services/hardcover-sync.service', () => ({ vi.mock('@/lib/services/hardcover-api.service', () => ({
fetchHardcoverList: fetchHardcoverListMock, fetchHardcoverList: fetchHardcoverListMock,
})); }));