mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -17,6 +17,48 @@ export interface HardcoverApiBook {
|
|||||||
coverUrl?: string;
|
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.
|
* Fetch a Hardcover List using their GraphQL API.
|
||||||
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
||||||
@@ -32,9 +74,9 @@ export async function fetchHardcoverList(
|
|||||||
if (isStatus) {
|
if (isStatus) {
|
||||||
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||||
const query = `
|
const query = `
|
||||||
query GetStatusBooks($statusId: Int!) {
|
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
|
||||||
me {
|
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 {
|
book {
|
||||||
id
|
id
|
||||||
title
|
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
|
// Map status numbers to names
|
||||||
const statusNames: Record<number, string> = {
|
const statusNames: Record<number, string> = {
|
||||||
1: 'Want to Read',
|
1: 'Want to Read',
|
||||||
@@ -81,30 +102,41 @@ export async function fetchHardcoverList(
|
|||||||
3: 'Read',
|
3: 'Read',
|
||||||
4: 'Did Not Finish',
|
4: 'Did Not Finish',
|
||||||
};
|
};
|
||||||
listName = statusNames[statusId] || `Status ${statusId}`;
|
const listName = statusNames[statusId] || `Status ${statusId}`;
|
||||||
|
|
||||||
const books: HardcoverApiBook[] = [];
|
const allBooks: HardcoverApiBook[] = [];
|
||||||
for (const item of userBooks) {
|
let offset = 0;
|
||||||
const book = item.book;
|
|
||||||
if (!book || !book.id) continue;
|
|
||||||
|
|
||||||
const authorName =
|
// Paginate until fewer results than PAGE_SIZE are returned
|
||||||
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
while (true) {
|
||||||
const cachedImg = book.cached_image;
|
const response = await axios.post(
|
||||||
const coverUrl =
|
HARDCOVER_API_URL,
|
||||||
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
|
||||||
book.image?.url ||
|
{
|
||||||
undefined;
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
books.push({
|
if (response.data?.errors) {
|
||||||
bookId: book.id.toString(),
|
throw new Error(
|
||||||
title: book.title || 'Unknown Title',
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||||
author: authorName,
|
);
|
||||||
coverUrl,
|
}
|
||||||
});
|
|
||||||
|
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 {
|
} 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 || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user