Merge pull request #126 from brombomb/hardcover-api

Unified Reading Shelves & Hardcover Integration
This commit is contained in:
kikootwo
2026-03-03 22:13:32 -05:00
committed by GitHub
31 changed files with 2987 additions and 315 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
# IDE # IDE
.idea .idea
.vscode
# Dependencies # Dependencies
/node_modules /node_modules
@@ -55,4 +56,4 @@ next-env.d.ts
/test-media /test-media
/test-data /test-data
/bookdrop /bookdrop
dockerfile.patch dockerfile.patch
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.14", "version": "1.0.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.14", "version": "1.0.15",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
@@ -299,7 +299,7 @@
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -309,7 +309,7 @@
"version": "7.28.5", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -343,7 +343,7 @@
"version": "7.28.5", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.28.5" "@babel/types": "^7.28.5"
@@ -403,7 +403,7 @@
"version": "7.28.5", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
+43
View File
@@ -66,6 +66,7 @@ model User {
bookDateRecommendations BookDateRecommendation[] bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[] bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[] goodreadsShelves GoodreadsShelf[]
hardcoverShelves HardcoverShelf[]
reportedIssues ReportedIssue[] @relation("Reporter") reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver") resolvedIssues ReportedIssue[] @relation("Resolver")
@@ -531,3 +532,45 @@ model GoodreadsBookMapping {
@@index([audibleAsin]) @@index([audibleAsin])
@@map("goodreads_book_mappings") @@map("goodreads_book_mappings")
} }
// ============================================================================
// HARDCOVER SYNC TABLES
// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache
// ============================================================================
model HardcoverShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from Hardcover API list name or status
listId String @map("list_id") // Hardcover List ID or Status ID
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")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, listId])
@@index([userId])
@@map("hardcover_shelves")
}
model HardcoverBookMapping {
id String @id @default(uuid())
hardcoverBookId String @unique @map("hardcover_book_id") // Internal ID from Hardcover
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([hardcoverBookId])
@@index([audibleAsin])
@@map("hardcover_book_mappings")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -7,9 +7,15 @@ 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 { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
const logger = RMABLogger.create('API.GoodreadsShelves'); const logger = RMABLogger.create('API.GoodreadsShelves');
const UpdateGoodreadsSchema = z.object({
rssUrl: z.string().url('Must be a valid URL'),
});
/** /**
* DELETE /api/user/goodreads-shelves/[id] * DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check) * Remove a Goodreads shelf subscription (ownership check)
@@ -48,3 +54,57 @@ export async function DELETE(
} }
}); });
} }
/**
* PATCH /api/user/goodreads-shelves/[id]
* Update a Goodreads shelf subscription
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } });
if (!shelf) {
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
}
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
// Force re-fetch by clearing metadata
const updated = await prisma.goodreadsShelf.update({
where: { id },
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
});
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 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
}
logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 });
}
});
}
+2 -2
View File
@@ -139,8 +139,8 @@ export async function POST(request: NextRequest) {
// Trigger immediate sync for this shelf (unlimited lookups, process all books) // Trigger immediate sync for this shelf (unlimited lookups, process all books)
try { try {
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0); await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`); logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
} catch (error) { } catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
} }
@@ -0,0 +1,144 @@
/**
* Component: Hardcover Shelf Delete Route
* Documentation: documentation/backend/services/hardcover-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { z } from 'zod';
const logger = RMABLogger.create('API.HardcoverShelves');
const UpdateHardcoverSchema = z.object({
listId: z.string().min(1, 'List ID is required').optional(),
apiToken: z.string().optional(),
});
/**
* DELETE /api/user/hardcover-shelves/[id]
* Remove a Hardcover shelf subscription (ownership check)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.hardcoverShelf.findUnique({
where: { id },
});
if (!shelf) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
// Ownership check
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.hardcoverShelf.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to delete list', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to delete list' },
{ status: 500 },
);
}
});
}
/**
* PATCH /api/user/hardcover-shelves/[id]
* Update a Hardcover shelf subscription
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } });
if (!shelf) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
const updateData: any = {};
let needsResync = false;
if (listId && listId !== shelf.listId) {
updateData.listId = listId;
needsResync = true;
}
if (apiToken && apiToken.trim() !== '') {
const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
? apiToken.trim().slice(7).trim()
: apiToken.trim();
const encryptionService = getEncryptionService();
updateData.apiToken = encryptionService.encrypt(cleanedToken);
needsResync = true;
}
// If we are forcing a resync due to a change, clear metadata
if (needsResync) {
updateData.lastSyncAt = null;
updateData.bookCount = null;
updateData.coverUrls = null;
}
const updated = await prisma.hardcoverShelf.update({
where: { id },
data: updateData,
});
if (needsResync) {
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 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 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
}
logger.error('Failed to update list', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to update list' }, { status: 500 });
}
});
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Component: Hardcover Shelves API Routes
* Documentation: documentation/backend/services/hardcover-sync.md
*/
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 { getJobQueueService } from '@/lib/services/job-queue.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
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' }),
});
/**
* GET /api/user/hardcover-shelves
* List the current user's Hardcover lists with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.hardcoverShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
let books: {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return {
id: shelf.id,
name: shelf.name,
listId: shelf.listId,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list Hardcover lists', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to list Hardcover lists' },
{ status: 500 },
);
}
});
}
/**
* POST /api/user/hardcover-shelves
* Add a new Hardcover list subscription
*/
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 req.json();
let { listId, apiToken } = AddShelfSchema.parse(body);
// Clean up token in case user pasted "Bearer " prefix
apiToken = apiToken.trim();
if (apiToken.toLowerCase().startsWith('bearer ')) {
apiToken = apiToken.slice(7).trim();
}
// Check for duplicate
const existing = await prisma.hardcoverShelf.findUnique({
where: { userId_listId: { userId: req.user.id, listId } },
});
if (existing) {
return NextResponse.json(
{
error: 'DuplicateShelf',
message: 'You have already added this list',
},
{ status: 409 },
);
}
// Validate by fetching the Hardcover GraphQL feed
let listName: string;
let bookCount: number;
let initialBooks: {
coverUrl: string;
asin: null;
title: string;
author: string;
}[] = [];
try {
const fetchedData = await fetchHardcoverList(apiToken, listId);
listName = fetchedData.listName;
bookCount = fetchedData.books.length;
initialBooks = fetchedData.books
.filter((b) => b.coverUrl)
.slice(0, 8)
.map((b) => ({
coverUrl: b.coverUrl!,
asin: null,
title: b.title,
author: b.author,
}));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidHardcoverList',
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 },
);
}
const encryptionService = getEncryptionService();
const encryptedToken = encryptionService.encrypt(apiToken);
const shelf = await prisma.hardcoverShelf.create({
data: {
userId: req.user.id,
name: listName,
listId,
apiToken: encryptedToken,
bookCount,
coverUrls:
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
logger.info(
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
}
return NextResponse.json(
{
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
listId: shelf.listId,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
},
{ status: 201 },
);
} catch (error) {
logger.error('Failed to add Hardcover list', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 },
);
}
return NextResponse.json(
{ error: 'Failed to add Hardcover list' },
{ status: 500 },
);
}
});
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Component: Combined Shelves API Routes
* 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 { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Shelves');
/**
* GET /api/user/shelves
* List the current user's shelves (Goodreads, Hardcover) with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [goodreads, hardcover] = await Promise.all([
prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
}),
prisma.hardcoverShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
}),
]);
const processBooks = (coverUrls: string | null) => {
let books: {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}[] = [];
if (coverUrls) {
const parsed = JSON.parse(coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return books;
};
const combined = [
...goodreads.map((s) => ({
id: s.id,
type: 'goodreads',
name: s.name,
sourceId: s.rssUrl,
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
books: processBooks(s.coverUrls),
})),
...hardcover.map((s) => ({
id: s.id,
type: 'hardcover',
name: s.name,
sourceId: s.listId,
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
books: processBooks(s.coverUrls),
})),
].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return NextResponse.json({ success: true, shelves: combined });
} catch (error) {
logger.error('Failed to list shelves', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to list shelves' },
{ status: 500 },
);
}
});
}
+3 -3
View File
@@ -11,7 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests'; import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; import { ShelvesSection } from '@/components/profile/ShelvesSection';
const statConfig = [ const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
@@ -139,8 +139,8 @@ export default function ProfilePage() {
</div> </div>
</section> </section>
{/* Goodreads Shelves */} {/* Generic Shelves Section */}
<GoodreadsShelvesSection /> <ShelvesSection />
{/* Active Downloads */} {/* Active Downloads */}
{activeDownloads.length > 0 && ( {activeDownloads.length > 0 && (
+51 -20
View File
@@ -12,7 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge'; import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { AddShelfModal } from '@/components/ui/AddShelfModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() { export function Header() {
@@ -21,8 +21,9 @@ export function Header() {
const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false); const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false); const [showAddShelfModal, setShowAddShelfModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); const { containerRef, dropdownRef, positionAbove, style } =
useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only) // Check if user can change password (local users only)
const canChangePassword = user?.authProvider === 'local'; const canChangePassword = user?.authProvider === 'local';
@@ -44,16 +45,14 @@ export function Header() {
const response = await fetch('/api/bookdate/config', { const response = await fetch('/api/bookdate/config', {
headers: { headers: {
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); });
const data = await response.json(); const data = await response.json();
// Show BookDate to any user with verified and enabled configuration // Show BookDate to any user with verified and enabled configuration
setShowBookDate( setShowBookDate(
data.config && data.config && data.config.isVerified && data.config.isEnabled,
data.config.isVerified &&
data.config.isEnabled
); );
} catch (error) { } catch (error) {
console.error('Failed to check BookDate config:', error); console.error('Failed to check BookDate config:', error);
@@ -95,11 +94,11 @@ export function Header() {
<button <button
onClick={() => { onClick={() => {
setShowUserMenu(false); setShowUserMenu(false);
setShowAddGoodreadsModal(true); setShowAddShelfModal(true);
}} }}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
Add Goodreads Shelf Add Shelf
</button> </button>
{canChangePassword && ( {canChangePassword && (
<button <button
@@ -206,8 +205,18 @@ export function Header() {
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md" className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
aria-label="Search" aria-label="Search"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg> </svg>
</Link> </Link>
@@ -218,12 +227,32 @@ export function Header() {
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{showMobileMenu ? ( {showMobileMenu ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
) : ( ) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg> </svg>
)} )}
</button> </button>
@@ -327,7 +356,9 @@ export function Header() {
</div> </div>
{/* User menu dropdown (rendered via portal) */} {/* User menu dropdown (rendered via portal) */}
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)} {typeof window !== 'undefined' &&
userMenuDropdown &&
createPortal(userMenuDropdown, document.body)}
{/* Change Password Modal */} {/* Change Password Modal */}
<ChangePasswordModal <ChangePasswordModal
@@ -335,10 +366,10 @@ export function Header() {
onClose={() => setShowChangePasswordModal(false)} onClose={() => setShowChangePasswordModal(false)}
/> />
{/* Add Goodreads Shelf Modal */} {/* Add Shelf Modal */}
<AddGoodreadsShelfModal <AddShelfModal
isOpen={showAddGoodreadsModal} isOpen={showAddShelfModal}
onClose={() => setShowAddGoodreadsModal(false)} onClose={() => setShowAddShelfModal(false)}
/> />
</header> </header>
); );
@@ -1,16 +1,21 @@
/** /**
* Component: Goodreads Shelves Section (Profile Page) * Component: Combined Shelves Section (Profile Page)
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
*/ */
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { AddShelfModal } from '@/components/ui/AddShelfModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { Modal } from '@/components/ui/Modal';
import { ManageShelfModal } from '@/components/ui/ManageShelfModal';
import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
function formatRelativeTime(dateStr: string | null): string { function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never'; if (!dateStr) return 'Never';
@@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string {
return `${diffDays}d ago`; return `${diffDays}d ago`;
} }
export function GoodreadsShelvesSection() { export function ShelvesSection() {
const { shelves, isLoading } = useGoodreadsShelves(); const { shelves, isLoading } = useShelves();
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf(); const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } =
useDeleteGoodreadsShelf();
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
useDeleteHardcoverShelf();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
const handleDelete = async (shelfId: string) => { const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddShelf, setShowAddShelf] = useState(false);
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
const [manageShelf, setManageShelf] = useState<GenericShelf | null>(null);
const handleDelete = async (shelf: GenericShelf) => {
try { try {
await deleteShelf(shelfId); if (shelf.type === 'goodreads') {
await deleteGoodreads(shelf.id);
} else {
await deleteHardcover(shelf.id);
}
setConfirmDeleteId(null); setConfirmDeleteId(null);
} catch { } catch {
// Error handled by hook // Error handled by hook
} }
}; };
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
return ( return (
<section> <section>
{/* Section Header */} {/* Section Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> className="w-[18px] h-[18px] text-emerald-600 dark:text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg> </svg>
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight"> <h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
Goodreads Shelves Shelves
</h2> </h2>
{!isLoading && shelves.length > 0 && ( {!isLoading && shelves.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5"> <p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '}
connected
</p> </p>
)} )}
</div> </div>
</div> </div>
<button {shelves.length > 0 && (
onClick={() => setShowAddModal(true)} <button
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" 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
</svg> className="w-4 h-4"
Add Shelf fill="none"
</button> 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>
{/* Content */} {/* Content */}
@@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() {
squareCovers={squareCovers} squareCovers={squareCovers}
isDeleting={isDeleting && confirmDeleteId === shelf.id} isDeleting={isDeleting && confirmDeleteId === shelf.id}
isConfirmingDelete={confirmDeleteId === shelf.id} isConfirmingDelete={confirmDeleteId === shelf.id}
onDelete={() => handleDelete(shelf.id)} onDelete={() => handleDelete(shelf)}
onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
onCancelDelete={() => setConfirmDeleteId(null)} onCancelDelete={() => setConfirmDeleteId(null)}
onManage={() => setManageShelf(shelf)}
onBookClick={(asin) => setSelectedAsin(asin)} onBookClick={(asin) => setSelectedAsin(asin)}
/> />
))} ))}
</div> </div>
) : ( ) : (
<EmptyState onAdd={() => setShowAddModal(true)} /> <EmptyState onAdd={() => setShowAddShelf(true)} />
)} )}
<AddGoodreadsShelfModal {/* Modals */}
isOpen={showAddModal} <AddShelfModal
onClose={() => setShowAddModal(false)} isOpen={showAddShelf}
onClose={() => setShowAddShelf(false)}
/>
<ManageShelfModal
isOpen={!!manageShelf}
onClose={() => setManageShelf(null)}
shelf={manageShelf}
/> />
{/* Audiobook Detail Modal (read-only) */}
{selectedAsin && ( {selectedAsin && (
<AudiobookDetailsModal <AudiobookDetailsModal
asin={selectedAsin} asin={selectedAsin}
@@ -122,9 +168,19 @@ export function GoodreadsShelvesSection() {
function EmptyState({ onAdd }: { onAdd: () => void }) { function EmptyState({ onAdd }: { onAdd: () => void }) {
return ( return (
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center"> <div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10"> <div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center mb-5 ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> className="w-7 h-7 text-emerald-500 dark:text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg> </svg>
</div> </div>
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
Connect your reading list Connect your reading list
</h3> </h3>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed"> <p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
Link a Goodreads shelf and we&apos;ll automatically request the audiobook for every book you add. Link a Goodreads or Hardcover shelf and we'll automatically request the
audiobook for every book you add.
</p> </p>
<button <button
onClick={onAdd} onClick={onAdd}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm" className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 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> </svg>
Add Your First Shelf Add Your First Shelf
</button> </button>
@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
key={i} key={i}
className={cn( className={cn(
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', 'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
)} )}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
/> />
@@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
/* ─── Shelf Card ─── */ /* ─── Shelf Card ─── */
interface ShelfCardProps { interface ShelfCardProps {
shelf: GoodreadsShelf; shelf: GenericShelf;
squareCovers: boolean; squareCovers: boolean;
isDeleting: boolean; isDeleting: boolean;
isConfirmingDelete: boolean; isConfirmingDelete: boolean;
onDelete: () => void; onDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onCancelDelete: () => void; onCancelDelete: () => void;
onManage: () => void;
onBookClick: (asin: string) => void; onBookClick: (asin: string) => void;
} }
@@ -197,20 +265,44 @@ function ShelfCard({
onDelete, onDelete,
onConfirmDelete, onConfirmDelete,
onCancelDelete, onCancelDelete,
onManage,
onBookClick, onBookClick,
}: ShelfCardProps) { }: ShelfCardProps) {
const displayBooks = shelf.books.slice(0, 6); const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0; const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length); const remainingCount = Math.max(
0,
(shelf.bookCount || 0) - displayBooks.length,
);
const isSyncing = !shelf.lastSyncAt; const isSyncing = !shelf.lastSyncAt;
const providerIcon =
shelf.type === 'goodreads' ? (
<img
src="/goodreads-icon.png"
alt="Goodreads"
className="w-5 h-5 ml-2 object-contain"
/>
) : (
<img
src="/hardcover-icon.svg"
alt="Hardcover"
className="w-5 h-5 ml-2 object-contain"
/>
);
return ( return (
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40"> <div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
{/* Top: Shelf info + actions */} {/* Top: Shelf info + actions */}
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}> <div
className={cn(
'flex items-start justify-between',
(hasCovers || isSyncing) && 'mb-5',
)}
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug"> <h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
{shelf.name} {shelf.name} {providerIcon}
</h3> </h3>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
{shelf.bookCount != null && ( {shelf.bookCount != null && (
@@ -259,22 +351,58 @@ function ShelfCard({
</button> </button>
</div> </div>
) : ( ) : (
<button <div className="flex items-center gap-1">
onClick={onConfirmDelete} <button
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100" onClick={onManage}
title="Remove shelf" className="p-2 text-gray-300 hover:text-blue-500 dark:text-gray-600 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
> title="Manage shelf"
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}> >
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> <svg
</svg> className="w-[18px] h-[18px]"
</button> fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
onClick={onConfirmDelete}
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove shelf"
>
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
)} )}
</div> </div>
</div> </div>
{/* Bottom: Stacked book covers */} {/* Bottom: Stacked book covers */}
{hasCovers ? ( {hasCovers ? (
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} /> <CoverStack
books={displayBooks}
remainingCount={remainingCount}
squareCovers={squareCovers}
onBookClick={onBookClick}
/>
) : isSyncing ? ( ) : isSyncing ? (
<div className="flex items-end"> <div className="flex items-end">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
@@ -282,7 +410,7 @@ function ShelfCard({
key={i} key={i}
className={cn( className={cn(
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', 'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
)} )}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
/> />
@@ -322,7 +450,7 @@ function CoverStack({
'transition-all duration-300 ease-out', 'transition-all duration-300 ease-out',
hoveredIndex === i && 'scale-[1.18] shadow-xl', hoveredIndex === i && 'scale-[1.18] shadow-xl',
coverSize, coverSize,
book.asin ? 'cursor-pointer' : 'cursor-default' book.asin ? 'cursor-pointer' : 'cursor-default',
)} )}
style={{ style={{
marginLeft: i > 0 ? '-16px' : 0, marginLeft: i > 0 ? '-16px' : 0,
@@ -331,7 +459,11 @@ function CoverStack({
onMouseEnter={() => setHoveredIndex(i)} onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)} onMouseLeave={() => setHoveredIndex(null)}
onClick={() => book.asin && onBookClick(book.asin)} onClick={() => book.asin && onBookClick(book.asin)}
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} title={
book.asin
? `${book.title}${book.author ? ` by ${book.author}` : ''}`
: undefined
}
> >
<img <img
src={book.coverUrl} src={book.coverUrl}
@@ -346,7 +478,7 @@ function CoverStack({
<div <div
className={cn( className={cn(
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800', 'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
coverSize coverSize,
)} )}
style={{ marginLeft: '-16px', zIndex: 0 }} style={{ marginLeft: '-16px', zIndex: 0 }}
> >
@@ -1,154 +0,0 @@
/**
* Component: Add Goodreads Shelf Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
interface AddGoodreadsShelfModalProps {
isOpen: boolean;
onClose: () => void;
}
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
const [rssUrl, setRssUrl] = useState('');
const [validationError, setValidationError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
const validateUrl = (url: string): boolean => {
if (!url.trim()) {
setValidationError('RSS URL is required');
return false;
}
if (!GOODREADS_RSS_PATTERN.test(url)) {
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
return false;
}
setValidationError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateUrl(rssUrl)) return;
try {
const shelf = await addShelf(rssUrl);
setSuccess(true);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} catch {
// Error is handled by the hook
}
};
const handleClose = () => {
setRssUrl('');
setValidationError('');
setSuccess(false);
setSuccessMessage('');
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
<div className="space-y-5">
{/* Visual header */}
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
</svg>
</div>
<div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
</p>
</div>
</div>
{/* Success alert */}
{success && (
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
</div>
)}
{/* Error alert */}
{error && (
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<Input
type="url"
label="Goodreads RSS URL"
value={rssUrl}
onChange={(e) => {
setRssUrl(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="https://www.goodreads.com/review/list_rss/..."
error={validationError}
disabled={isLoading || success}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
Find it on Goodreads: My Books &rarr; select a shelf &rarr; RSS link at the bottom of the page.
</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isLoading}
disabled={isLoading || success}
>
Add Shelf
</Button>
</div>
</form>
</div>
</Modal>
);
}
+366
View File
@@ -0,0 +1,366 @@
/**
* Component: Add Shelf Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
interface AddShelfModalProps {
isOpen: boolean;
onClose: () => void;
}
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>(
'goodreads',
);
// Goodreads State
const [rssUrl, setRssUrl] = useState('');
// Hardcover State
const [apiToken, setApiToken] = useState('');
const [listType, setListType] = useState<'status' | 'custom'>('status');
const [statusId, setStatusId] = useState('1'); // 1 = Want to Read
const [customListId, setCustomListId] = useState('');
const [validationError, setValidationError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const {
addShelf: addGoodreads,
isLoading: isGoodreadsLoading,
error: goodreadsError,
} = useAddGoodreadsShelf();
const {
addShelf: addHardcover,
isLoading: isHardcoverLoading,
error: hardcoverError,
} = useAddHardcoverShelf();
const isLoading = isGoodreadsLoading || isHardcoverLoading;
const currentError =
provider === 'goodreads' ? goodreadsError : hardcoverError;
const validateInput = (): boolean => {
if (provider === 'goodreads') {
if (!rssUrl.trim()) {
setValidationError('RSS URL is required');
return false;
}
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
setValidationError(
'Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
);
return false;
}
} else {
if (!apiToken.trim()) {
setValidationError('Hardcover API Token is required');
return false;
}
if (listType === 'custom' && !customListId.trim()) {
setValidationError('Hardcover List URL or Slug is required');
return false;
}
}
setValidationError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateInput()) return;
try {
if (provider === 'goodreads') {
const shelf = await addGoodreads(rssUrl);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
} else {
const finalId =
listType === 'status' ? `status-${statusId}` : customListId.trim();
let cleanedToken = apiToken.trim();
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
cleanedToken = cleanedToken.slice(7).trim();
}
const shelf = await addHardcover(cleanedToken, finalId);
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
setApiToken('');
setCustomListId('');
}
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} catch {
// Error is handled by the hooks
}
};
const handleClose = () => {
setRssUrl('');
setApiToken('');
setCustomListId('');
setValidationError('');
setSuccess(false);
setSuccessMessage('');
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
<div className="space-y-5">
{/* Provider Selection Tabs */}
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
provider === 'goodreads'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
onClick={() => {
setProvider('goodreads');
setValidationError('');
}}
>
Goodreads
</button>
<button
type="button"
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
provider === 'hardcover'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
onClick={() => {
setProvider('hardcover');
setValidationError('');
}}
>
Hardcover
</button>
</div>
{/* Visual header */}
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
{provider === 'goodreads' ? (
<>
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
<img
src="/goodreads-icon.png"
alt="Goodreads"
className="w-5 h-5 object-contain"
/>
</div>
<div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Paste your Goodreads shelf RSS URL. Books will be
automatically requested.
</p>
</div>
</>
) : (
<>
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0">
<img
src="/hardcover-icon.svg"
alt="Hardcover"
className="w-6 h-6 object-contain"
/>
</div>
<div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Provide your Hardcover API token and select the list you want
to sync.
</p>
</div>
</>
)}
</div>
{/* Success alert */}
{success && (
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-emerald-600 dark:text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{successMessage}
</p>
</div>
)}
{/* Error alert */}
{currentError && (
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{currentError}
</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{provider === 'goodreads' ? (
<div>
<Input
type="url"
label="Goodreads RSS URL"
value={rssUrl}
onChange={(e) => {
setRssUrl(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="https://www.goodreads.com/review/list_rss/..."
error={validationError}
disabled={isLoading || success}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
Find it on Goodreads: My Books &rarr; select a shelf &rarr; RSS
link at the bottom of the page.
</p>
</div>
) : (
<div className="space-y-4">
<Input
type="text"
label="API Token"
value={apiToken}
onChange={(e) => {
setApiToken(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="eyJhb..."
disabled={isLoading || success}
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
List to Sync
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
className="form-radio text-indigo-600"
checked={listType === 'status'}
onChange={() => setListType('status')}
disabled={isLoading || success}
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
My Status
</span>
</label>
<label className="flex items-center">
<input
type="radio"
className="form-radio text-indigo-600"
checked={listType === 'custom'}
onChange={() => setListType('custom')}
disabled={isLoading || success}
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Custom List
</span>
</label>
</div>
</div>
{listType === 'status' ? (
<div>
<select
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
value={statusId}
onChange={(e) => setStatusId(e.target.value)}
disabled={isLoading || success}
>
<option value="1">Want to Read</option>
<option value="2">Currently Reading</option>
<option value="3">Read</option>
<option value="4">Did Not Finish</option>
</select>
</div>
) : (
<Input
type="text"
label="List URL or Slug"
value={customListId}
onChange={(e) => {
setCustomListId(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="https://hardcover.app/@username/lists/..."
error={validationError}
disabled={isLoading || success}
/>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isLoading}
disabled={isLoading || success}
>
Add Shelf
</Button>
</div>
</form>
</div>
</Modal>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { GenericShelf } from '@/lib/hooks/useShelves';
import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { cn } from '@/lib/utils/cn';
interface ManageShelfModalProps {
shelf: GenericShelf | null;
isOpen: boolean;
onClose: () => void;
}
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 [apiToken, setApiToken] = useState('');
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf();
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf();
// Reset form when shelf changes
React.useEffect(() => {
if (shelf) {
setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : '');
setListId(shelf.type === 'hardcover' ? shelf.sourceId : '');
setApiToken('');
}
}, [shelf]);
if (!shelf) return null;
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (shelf.type === 'goodreads') {
if (!rssUrl.trim()) return;
await updateGoodreads(shelf.id, rssUrl.trim());
} else {
if (!listId.trim()) return;
await updateHardcover(shelf.id, {
listId: listId.trim(),
apiToken: apiToken.trim() || undefined,
});
}
onClose();
} catch (err) {
// Error is handled by hook
}
};
const isGoodreads = shelf.type === 'goodreads';
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-5">
{isGoodreads ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Goodreads RSS URL
</label>
<input
type="url"
required
value={rssUrl}
onChange={(e) => setRssUrl(e.target.value)}
placeholder="https://www.goodreads.com/review/list_rss/..."
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hardcover List ID or Slug
</label>
<input
type="text"
required
value={listId}
onChange={(e) => setListId(e.target.value)}
placeholder="e.g., 1234, want-to-read, status-1"
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New API Token <span className="text-gray-400 dark:text-gray-500 font-normal">(Leave blank to keep current)</span>
</label>
<input
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder="Paste your Hardcover token here..."
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
</>
)}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
disabled={isUpdating}
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className={cn(
'px-6 py-2 text-sm font-medium text-white rounded-xl shadow-sm transition-colors',
isGoodreads
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-indigo-600 hover:bg-indigo-700',
isUpdating && 'opacity-50 cursor-not-allowed',
)}
>
{isUpdating ? 'Saving...' : 'Update & Re-sync'}
</button>
</div>
</form>
</div>
</Modal>
);
}
+14 -5
View File
@@ -5,7 +5,8 @@
'use client'; 'use client';
import React, { useEffect, useRef, useCallback } from 'react'; import React, { useEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
interface ModalProps { interface ModalProps {
@@ -25,6 +26,12 @@ export function Modal({
size = 'md', size = 'md',
showCloseButton = true, showCloseButton = true,
}: ModalProps) { }: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Use ref to avoid re-running effect when onClose changes // Use ref to avoid re-running effect when onClose changes
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
onCloseRef.current = onClose; onCloseRef.current = onClose;
@@ -53,7 +60,7 @@ export function Modal({
}; };
}, [isOpen, handleClose]); }, [isOpen, handleClose]);
if (!isOpen) return null; if (!isOpen || !mounted) return null;
const sizeClasses = { const sizeClasses = {
sm: 'max-w-md', sm: 'max-w-md',
@@ -63,8 +70,8 @@ export function Modal({
full: 'max-w-[95vw]', full: 'max-w-[95vw]',
}; };
return ( const content = (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-[100] overflow-y-auto">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
@@ -77,7 +84,7 @@ export function Modal({
className={cn( className={cn(
'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl', 'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl',
'transform transition-all', 'transform transition-all',
sizeClasses[size] sizeClasses[size],
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -116,4 +123,6 @@ export function Modal({
</div> </div>
</div> </div>
); );
return createPortal(content, document.body);
} }
+50
View File
@@ -125,3 +125,53 @@ export function useDeleteGoodreadsShelf() {
return { deleteShelf, isLoading, error }; return { deleteShelf, isLoading, error };
} }
export function useUpdateGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateShelf = async (shelfId: string, rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/goodreads-shelves/${shelfId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update shelf');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/goodreads-shelves'),
);
mutate(
(key) => typeof key === 'string' && key.includes('/api/user/shelves'),
);
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { updateShelf, isLoading, error };
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Component: Hardcover Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export interface HardcoverShelf {
id: string;
name: string;
listId: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
export function useHardcoverShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/hardcover-shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as HardcoverShelf[],
isLoading,
error,
};
}
export function useAddHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (apiToken: string, listId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/hardcover-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiToken, listId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
return data.shelf as HardcoverShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
export function useDeleteHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/hardcover-shelves/${shelfId}`,
{
method: 'DELETE',
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
export function useUpdateHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateShelf = async (
shelfId: string,
updates: { listId?: string; apiToken?: string },
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(
`/api/user/hardcover-shelves/${shelfId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
},
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update list');
}
// Revalidate shelves list
mutate(
(key) =>
typeof key === 'string' &&
key.includes('/api/user/hardcover-shelves'),
);
mutate(
(key) => typeof key === 'string' && key.includes('/api/user/shelves'),
);
return data.shelf as HardcoverShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { updateShelf, isLoading, error };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Component: Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { ShelfBook } from './useGoodreadsShelves';
export interface GenericShelf {
id: string;
type: 'goodreads' | 'hardcover';
name: string;
sourceId: string; // Either rssUrl or listId
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
export function useShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as GenericShelf[],
isLoading,
error,
};
}
@@ -1,42 +0,0 @@
/**
* Component: Sync Goodreads Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing Goodreads shelf RSS feeds.
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncGoodreadsShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
const { jobId, shelfId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
logger.info(shelfId
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
: 'Starting scheduled Goodreads shelves sync...'
);
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
const stats = await processGoodreadsShelves(logger, {
shelfId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
logger.info('Goodreads sync complete', { stats });
return {
success: true,
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
...stats,
};
}
@@ -0,0 +1,96 @@
/**
* Component: Sync Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing all reading shelves (Goodreads, Hardcover).
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** The type of shelf, if shelfId is specified */
shelfType?: 'goodreads' | 'hardcover';
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncShelves(
payload: SyncShelvesPayload,
): Promise<any> {
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
const stats = {
shelvesProcessed: 0,
booksFound: 0,
lookupsPerformed: 0,
requestsCreated: 0,
errors: 0,
};
logger.info(
shelfId
? `Starting immediate ${shelfType} sync for list ${shelfId}...`
: 'Starting scheduled shelves sync...',
);
const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads';
const shouldSyncHardcover = !shelfType || shelfType === 'hardcover';
if (shouldSyncGoodreads) {
try {
const { processGoodreadsShelves } =
await import('../services/goodreads-sync.service');
const grStats = await processGoodreadsShelves(logger, {
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
stats.shelvesProcessed += grStats.shelvesProcessed;
stats.booksFound += grStats.booksFound;
stats.lookupsPerformed += grStats.lookupsPerformed;
stats.requestsCreated += grStats.requestsCreated;
stats.errors += grStats.errors;
} catch (error) {
logger.error('Goodreads sync failed', {
error: error instanceof Error ? error.message : String(error),
});
stats.errors++;
}
}
if (shouldSyncHardcover) {
try {
const { processHardcoverShelves } =
await import('../services/hardcover-sync.service');
const hcStats = await processHardcoverShelves(logger, {
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
stats.shelvesProcessed += hcStats.shelvesProcessed;
stats.booksFound += hcStats.booksFound;
stats.lookupsPerformed += hcStats.lookupsPerformed;
stats.requestsCreated += hcStats.requestsCreated;
stats.errors += hcStats.errors;
} catch (error) {
logger.error('Hardcover sync failed', {
error: error instanceof Error ? error.message : String(error),
});
stats.errors++;
}
}
logger.info('Shelves sync complete', { stats });
return {
success: true,
message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced',
...stats,
};
}
+598
View File
@@ -0,0 +1,598 @@
/**
* Component: Hardcover Shelf Sync Service
* Documentation: documentation/backend/services/hardcover-sync.md
*
* Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs,
* and creates requests via the shared request-creator service.
*/
import axios from 'axios';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('HardcoverSync');
/** Default max Audible lookups per shelf per scheduled sync cycle */
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
/** Days before retrying a noMatch book */
const NO_MATCH_RETRY_DAYS = 7;
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
interface HardcoverApiBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/**
* 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!) {
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 || [];
let listName = 'Hardcover Status List';
// Map status numbers to names
const statusNames: Record<number, string> = {
1: 'Want to Read',
2: 'Currently Reading',
3: 'Read',
4: 'Did Not Finish',
};
listName = statusNames[statusId] || `Status ${statusId}`;
const books: HardcoverApiBook[] = [];
for (const item of userBooks) {
const book = item.book;
if (!book || !book.id) continue;
const authorName =
book.contributions?.[0]?.author?.name || 'Unknown Author';
const coverUrl = book.cached_image || book.image?.url || undefined;
books.push({
bookId: book.id.toString(),
title: book.title || 'Unknown Title',
author: authorName,
coverUrl,
});
}
return { listName, books };
} else {
// Original list_books logic
let isUuid = false;
let isIntId = false;
let extractedSlug = listIdStr;
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
listIdStr,
)
) {
isUuid = true;
} else if (/^\d+$/.test(listIdStr)) {
isIntId = true;
} else {
try {
if (listIdStr.includes('hardcover.app')) {
const url = new URL(
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
);
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length > 0) {
extractedSlug = parts[parts.length - 1];
}
}
} catch (e) {
// use extractedSlug as-is
}
}
const query = `
query GetListBooks($listId: Int!) {
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
list { name }
book {
id title cached_image image { url }
contributions { author { name } }
}
}
}
`;
const queryUuid = `
query GetListBooksUuid($listId: uuid!) {
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
list { name }
book {
id title cached_image image { url }
contributions { author { name } }
}
}
}
`;
const querySlug = `
query GetListBooksBySlug($slug: String!) {
lists(where: {slug: {_eq: $slug}}, limit: 1) {
name
list_books(limit: 100, order_by: {id: desc}) {
book {
id title cached_image image { url }
contributions { author { name } }
}
}
}
}
`;
const isSlug = !isUuid && !isIntId;
const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query;
const variables = isSlug
? { slug: extractedSlug }
: { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) };
const response = await axios.post(
HARDCOVER_API_URL,
{
query: activeQuery,
variables,
},
{
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}`,
);
}
let listName = 'Hardcover List';
let listBooks: any[] = [];
if (isSlug) {
const listsData = response.data?.data?.lists || [];
if (listsData.length === 0) {
throw new Error(`Could not find a list with slug "${extractedSlug}"`);
}
listName = listsData[0].name || listName;
listBooks = listsData[0].list_books || [];
} else {
listBooks = response.data?.data?.list_books || [];
if (listBooks.length > 0 && listBooks[0].list?.name) {
listName = listBooks[0].list.name;
}
}
const books: HardcoverApiBook[] = [];
for (const item of listBooks) {
const book = item.book;
if (!book || !book.id) continue;
const authorName =
book.contributions?.[0]?.author?.name || 'Unknown Author';
const coverUrl = book.cached_image || book.image?.url || undefined;
books.push({
bookId: book.id.toString(),
title: book.title || 'Unknown Title',
author: authorName,
coverUrl,
});
}
return { listName, books };
}
}
export interface HardcoverSyncStats {
shelvesProcessed: number;
booksFound: number;
lookupsPerformed: number;
requestsCreated: number;
errors: number;
}
export interface HardcoverSyncOptions {
shelfId?: string;
maxLookupsPerShelf?: number;
}
export async function processHardcoverShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: HardcoverSyncOptions = {},
): Promise<HardcoverSyncStats> {
const log = jobLogger || logger;
const stats: HardcoverSyncStats = {
shelvesProcessed: 0,
booksFound: 0,
lookupsPerformed: 0,
requestsCreated: 0,
errors: 0,
};
const maxLookups =
options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.hardcoverShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
});
if (shelves.length === 0) {
log.info(
options.shelfId
? 'Hardcover list not found'
: 'No Hardcover lists configured, skipping',
);
return stats;
}
log.info(
`Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`,
);
for (const shelf of shelves) {
try {
await processShelf(shelf, stats, log, maxLookups);
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
log.error(
`Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
log.info(
`Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`,
);
return stats;
}
async function processShelf(
shelf: {
id: string;
listId: string;
apiToken: string;
name: string;
user: { id: string; plexUsername: string };
},
stats: HardcoverSyncStats,
log:
| ReturnType<typeof RMABLogger.forJob>
| ReturnType<typeof RMABLogger.create>,
maxLookups: number,
) {
log.info(
`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`,
);
const encryptionService = getEncryptionService();
let decryptedToken = shelf.apiToken;
try {
// Check if the token is encrypted (our new storage method format)
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
decryptedToken = encryptionService.decrypt(shelf.apiToken);
}
} catch (err) {
log.error(
`Failed to decrypt API token for user ${shelf.user.plexUsername}`,
);
}
let fetchedData: { listName: string; books: HardcoverApiBook[] };
try {
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
} catch (error) {
log.error(
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return;
}
const books = fetchedData.books;
stats.booksFound += books.length;
log.info(
`Found ${books.length} books in list "${shelf.name}" (Hardcover API)`,
);
let lookupsThisCycle = 0;
const unlimitedLookups = maxLookups === 0;
for (const book of books) {
let mapping = await prisma.hardcoverBookMapping.findUnique({
where: { hardcoverBookId: book.bookId },
});
if (!mapping) {
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
mapping = await performAudibleLookup(book, log);
lookupsThisCycle++;
stats.lookupsPerformed++;
if (!mapping?.audibleAsin) continue;
}
if (mapping.noMatch) {
if (mapping.lastSearchAt) {
const daysSinceSearch =
(Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
if (
daysSinceSearch >= NO_MATCH_RETRY_DAYS &&
(unlimitedLookups || lookupsThisCycle < maxLookups)
) {
log.info(
`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`,
);
mapping = await performAudibleLookup(book, log, mapping.id);
lookupsThisCycle++;
stats.lookupsPerformed++;
if (!mapping?.audibleAsin) continue;
} else {
continue;
}
} else {
continue;
}
}
if (mapping.audibleAsin) {
try {
const result = await createRequestForUser(shelf.user.id, {
asin: mapping.audibleAsin,
title: mapping.title,
author: mapping.author,
coverArtUrl: mapping.coverUrl || undefined,
});
if (result.success) {
stats.requestsCreated++;
log.info(
`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`,
);
}
} catch (error) {
log.error(
`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}
// Collect enriched book data for display
const bookIds = books.map((b) => b.bookId);
const mappings =
bookIds.length > 0
? await prisma.hardcoverBookMapping.findMany({
where: { hardcoverBookId: { in: bookIds } },
select: {
hardcoverBookId: true,
audibleAsin: true,
title: true,
author: true,
coverUrl: true,
},
})
: [];
const mappingsByBookId = new Map(mappings.map((m) => [m.hardcoverBookId, m]));
const matchedAsins = mappings
.map((m) => m.audibleAsin)
.filter((asin): asin is string => !!asin);
const cachedCovers =
matchedAsins.length > 0
? await prisma.audibleCache.findMany({
where: { asin: { in: matchedAsins } },
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
})
: [];
const coverByAsin = new Map(
cachedCovers
.filter((c) => c.cachedCoverPath || c.coverArtUrl)
.map((c) => {
let coverUrl = c.coverArtUrl || '';
if (c.cachedCoverPath) {
const filename = c.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return [c.asin, coverUrl] as const;
}),
);
const bookData = books
.map((b) => {
const mapping = mappingsByBookId.get(b.bookId);
const coverUrl =
coverByAsin.get(mapping?.audibleAsin || '') ||
mapping?.coverUrl ||
b.coverUrl;
if (!coverUrl) return null;
return {
coverUrl,
asin: mapping?.audibleAsin || null,
title: mapping?.title || b.title,
author: mapping?.author || b.author,
};
})
.filter((b): b is NonNullable<typeof b> => b !== null)
.slice(0, 8);
const finalListName =
fetchedData.listName !== 'Hardcover List'
? fetchedData.listName
: shelf.name;
await prisma.hardcoverShelf.update({
where: { id: shelf.id },
data: {
name: finalListName,
lastSyncAt: new Date(),
bookCount: books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
}
async function performAudibleLookup(
book: HardcoverApiBook,
log:
| ReturnType<typeof RMABLogger.forJob>
| ReturnType<typeof RMABLogger.create>,
existingMappingId?: string,
): Promise<any> {
const audibleService = getAudibleService();
try {
const fullQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${fullQuery}"`);
let searchResult = await audibleService.search(fullQuery);
let firstResult = searchResult.results[0];
if (!firstResult?.asin) {
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
if (cleanTitle !== book.title) {
const cleanQuery = `${cleanTitle} ${book.author}`;
log.info(
`No results with full title, retrying without series info: "${cleanQuery}"`,
);
searchResult = await audibleService.search(cleanQuery);
firstResult = searchResult.results[0];
}
}
if (firstResult?.asin) {
log.info(
`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`,
);
const data = {
title: firstResult.title,
author: firstResult.author,
audibleAsin: firstResult.asin,
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
noMatch: false,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.hardcoverBookMapping.update({
where: { id: existingMappingId },
data,
});
}
return prisma.hardcoverBookMapping.create({
data: { hardcoverBookId: book.bookId, ...data },
});
}
log.info(`No Audible match for "${book.title}" by ${book.author}`);
const noMatchData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
audibleAsin: null,
};
if (existingMappingId) {
return prisma.hardcoverBookMapping.update({
where: { id: existingMappingId },
data: noMatchData,
});
}
return prisma.hardcoverBookMapping.create({
data: { hardcoverBookId: book.bookId, ...noMatchData },
});
} catch (error) {
log.error(
`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`,
);
const errorData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.hardcoverBookMapping.update({
where: { id: existingMappingId },
data: errorData,
});
}
return prisma.hardcoverBookMapping.create({
data: { hardcoverBookId: book.bookId, ...errorData },
});
}
}
+12 -10
View File
@@ -26,7 +26,7 @@ export type JobType =
| 'retry_failed_imports' | 'retry_failed_imports'
| 'cleanup_seeded_torrents' | 'cleanup_seeded_torrents'
| 'monitor_rss_feeds' | 'monitor_rss_feeds'
| 'sync_goodreads_shelves' | 'sync_reading_shelves'
| 'send_notification' | 'send_notification'
// Ebook-specific job types // Ebook-specific job types
| 'search_ebook' | 'search_ebook'
@@ -107,9 +107,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string; scheduledJobId?: string;
} }
export interface SyncGoodreadsShelvesPayload extends JobPayload { export interface SyncShelvesPayload extends JobPayload {
scheduledJobId?: string; scheduledJobId?: string;
shelfId?: string; shelfId?: string;
shelfType?: 'goodreads' | 'hardcover';
maxLookupsPerShelf?: number; maxLookupsPerShelf?: number;
} }
@@ -378,10 +379,10 @@ export class JobQueueService {
return await processCleanupSeededTorrents(payloadWithJobId); return await processCleanupSeededTorrents(payloadWithJobId);
}); });
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => { this.queue.process('sync_reading_shelves', 1, async (job: BullJob<SyncShelvesPayload>) => {
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor'); const { processSyncShelves } = await import('../processors/sync-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves'); const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves');
return await processSyncGoodreadsShelves(payloadWithJobId); return await processSyncShelves(payloadWithJobId);
}); });
// Send notification processor // Send notification processor
@@ -750,16 +751,17 @@ export class JobQueueService {
} }
/** /**
* Add sync Goodreads shelves job * Add sync reading shelves job
*/ */
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> { async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
return await this.addJob( return await this.addJob(
'sync_goodreads_shelves', 'sync_reading_shelves',
{ {
scheduledJobId, scheduledJobId,
shelfId, shelfId,
shelfType,
maxLookupsPerShelf, maxLookupsPerShelf,
} as SyncGoodreadsShelvesPayload, } as SyncShelvesPayload,
{ {
priority: 7, priority: 7,
} }
+36 -8
View File
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler'); const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves'; export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves';
export interface ScheduledJob { export interface ScheduledJob {
id: string; id: string;
@@ -59,6 +59,9 @@ export class SchedulerService {
}); });
} }
// Clean up deprecated scheduled jobs
await this.cleanupDeprecatedJobs();
// Create default jobs if they don't exist // Create default jobs if they don't exist
await this.ensureDefaultJobs(); await this.ensureDefaultJobs();
@@ -127,8 +130,8 @@ export class SchedulerService {
payload: {}, payload: {},
}, },
{ {
name: 'Sync Goodreads Shelves', name: 'Sync Reading Shelves',
type: 'sync_goodreads_shelves' as ScheduledJobType, type: 'sync_reading_shelves' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default enabled: true, // Enable by default
payload: {}, payload: {},
@@ -167,6 +170,31 @@ export class SchedulerService {
} }
} }
/**
* Remove any old jobs that are no longer supported
*/
private async cleanupDeprecatedJobs(): Promise<void> {
try {
const deprecatedTypes = ['sync_goodreads_shelves'];
const obsoleteJobs = await prisma.scheduledJob.findMany({
where: { type: { in: deprecatedTypes } },
});
for (const job of obsoleteJobs) {
if (job.enabled) {
await this.unscheduleJob(job);
}
await prisma.scheduledJob.delete({ where: { id: job.id } });
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
}
} catch (error) {
logger.error('Failed to cleanup deprecated scheduled jobs', {
error: error instanceof Error ? error.message : String(error),
});
}
}
/** /**
* Schedule all enabled jobs * Schedule all enabled jobs
*/ */
@@ -350,8 +378,8 @@ export class SchedulerService {
case 'monitor_rss_feeds': case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job); bullJobId = await this.triggerMonitorRssFeeds(job);
break; break;
case 'sync_goodreads_shelves': case 'sync_reading_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job); bullJobId = await this.triggerSyncShelves(job);
break; break;
default: default:
throw new Error(`Unknown job type: ${job.type}`); throw new Error(`Unknown job type: ${job.type}`);
@@ -622,10 +650,10 @@ export class SchedulerService {
} }
/** /**
* Trigger Goodreads shelves sync * Trigger Reading shelves sync
*/ */
private async triggerSyncGoodreadsShelves(job: any): Promise<string> { private async triggerSyncShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id); return await this.jobQueue.addSyncShelvesJob(job.id);
} }
} }
@@ -0,0 +1,186 @@
/**
* Component: Goodreads Shelves [id] API Route Tests
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
const SHELF = {
id: 'shelf-1',
userId: 'user-1',
name: 'Want to Read',
rssUrl: 'https://www.goodreads.com/review/list_rss/12345',
lastSyncAt: null,
bookCount: 5,
coverUrls: null,
createdAt: new Date().toISOString(),
};
describe('DELETE /api/user/goodreads-shelves/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 404 when shelf does not exist', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null);
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Shelf not found');
});
it('returns 403 when shelf belongs to another user', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('deletes the shelf and returns success', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.goodreadsShelf.delete.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.goodreadsShelf.delete).toHaveBeenCalledWith({ where: { id: 'shelf-1' } });
});
it('returns 500 when deletion throws', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.goodreadsShelf.delete.mockRejectedValueOnce(new Error('db error'));
const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('Failed to delete shelf');
});
});
describe('PATCH /api/user/goodreads-shelves/[id]', () => {
const NEW_RSS = 'https://www.goodreads.com/review/list_rss/99999';
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 404 when shelf does not exist', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null);
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
{ params: Promise.resolve({ id: 'shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Shelf not found');
});
it('returns 403 when shelf belongs to another user', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
{ params: Promise.resolve({ id: 'shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('returns 400 for an invalid (non-URL) rssUrl', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ rssUrl: 'not-a-url' }) } as any,
{ params: Promise.resolve({ id: 'shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('updates the shelf, clears sync metadata, and triggers a sync job', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
const updatedShelf = { ...SHELF, rssUrl: NEW_RSS, lastSyncAt: null };
prismaMock.goodreadsShelf.update.mockResolvedValueOnce(updatedShelf);
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
{ params: Promise.resolve({ id: 'shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.goodreadsShelf.update).toHaveBeenCalledWith({
where: { id: 'shelf-1' },
data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null },
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0);
});
it('still returns 200 even when the sync job fails to enqueue', async () => {
prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.goodreadsShelf.update.mockResolvedValueOnce({ ...SHELF, rssUrl: NEW_RSS });
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any,
{ params: Promise.resolve({ id: 'shelf-1' }) }
);
const payload = await response.json();
// Sync job failure is swallowed; shelf update should still succeed
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
});
});
@@ -0,0 +1,222 @@
/**
* Component: Hardcover Shelves [id] API Route Tests
* Documentation: documentation/backend/services/hardcover-sync.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
}));
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((s: string) => `enc:${s}`),
decrypt: vi.fn((s: string) => s.replace('enc:', '')),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
const SHELF = {
id: 'hc-shelf-1',
userId: 'user-1',
name: 'Currently Reading',
listId: 'status-2',
apiToken: 'enc:secret-token',
lastSyncAt: null,
bookCount: 3,
coverUrls: null,
createdAt: new Date().toISOString(),
};
describe('DELETE /api/user/hardcover-shelves/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 404 when list does not exist', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('List not found');
});
it('returns 403 when list belongs to another user', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('deletes the list and returns success', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.delete.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.hardcoverShelf.delete).toHaveBeenCalledWith({ where: { id: 'hc-shelf-1' } });
});
it('returns 500 when deletion throws', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.delete.mockRejectedValueOnce(new Error('db error'));
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('Failed to delete list');
});
});
describe('PATCH /api/user/hardcover-shelves/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 404 when list does not exist', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('List not found');
});
it('returns 403 when list belongs to another user', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('does not trigger a sync when no fields changed', async () => {
// listId is the same as existing; no apiToken provided
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: SHELF.listId }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
});
it('updates listId, clears metadata, and triggers a sync job', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(updated);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0);
});
it('encrypts the apiToken before persisting', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
await PATCH(
{ json: vi.fn().mockResolvedValue({ apiToken: 'my-raw-token' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({ apiToken: 'enc:my-raw-token' }),
});
});
it('strips the Bearer prefix before encrypting the token', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
await PATCH(
{ json: vi.fn().mockResolvedValue({ apiToken: 'Bearer my-raw-token' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
});
it('still returns 200 even when the sync job fails to enqueue', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce({ ...SHELF, listId: 'status-3' });
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Component: Hardcover Shelves API Route Tests (POST / GET)
* Documentation: documentation/backend/services/hardcover-sync.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
}));
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((s: string) => `enc:${s}`),
decrypt: vi.fn((s: string) => s.replace('enc:', '')),
}));
const fetchHardcoverListMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/services/hardcover-sync.service', () => ({
fetchHardcoverList: fetchHardcoverListMock,
}));
const FETCHED_LIST = {
listName: 'Currently Reading',
books: [
{ title: 'Dune', author: 'Frank Herbert', coverUrl: 'https://example.com/dune.jpg' },
{ title: 'Foundation', author: 'Isaac Asimov', coverUrl: null },
],
};
describe('POST /api/user/hardcover-shelves', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
json: vi.fn().mockResolvedValue({ listId: 'status-2', apiToken: 'raw-token' }),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 400 when apiToken is missing', async () => {
authRequest.json.mockResolvedValueOnce({ listId: 'status-2' });
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 400 when listId is missing', async () => {
authRequest.json.mockResolvedValueOnce({ apiToken: 'raw-token' });
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 409 when the list is already subscribed', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ id: 'existing-shelf' });
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('DuplicateShelf');
});
it('returns 400 when Hardcover API fetch fails', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
fetchHardcoverListMock.mockRejectedValueOnce(new Error('Invalid token'));
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('InvalidHardcoverList');
expect(payload.message).toContain('Invalid token');
});
it('creates the shelf with an encrypted token and triggers sync', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
id: 'new-shelf',
name: 'Currently Reading',
listId: 'status-2',
lastSyncAt: null,
createdAt: new Date().toISOString(),
bookCount: 2,
coverUrls: null,
});
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.success).toBe(true);
expect(payload.shelf.name).toBe('Currently Reading');
// Token must have been encrypted before storage
expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token');
expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
apiToken: 'enc:raw-token',
listId: 'status-2',
userId: 'user-1',
}),
})
);
// Immediate background sync must have been triggered
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0);
});
it('strips Bearer prefix from apiToken before encrypting', async () => {
authRequest.json.mockResolvedValueOnce({ listId: 'status-2', apiToken: 'Bearer raw-token' });
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
id: 'new-shelf-2',
name: 'Currently Reading',
listId: 'status-2',
lastSyncAt: null,
createdAt: new Date().toISOString(),
bookCount: 2,
coverUrls: null,
});
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
await POST({} as any);
// "Bearer " prefix must have been stripped before encrypt was called
expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token');
});
it('returns 201 even when the sync job fails to enqueue', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST);
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
id: 'new-shelf-3',
name: 'Currently Reading',
listId: 'status-2',
lastSyncAt: null,
createdAt: new Date().toISOString(),
bookCount: 2,
coverUrls: null,
});
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.success).toBe(true);
});
it('only includes books with cover URLs in the initial shelf preview', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); // only 1 of 2 books has coverUrl
prismaMock.hardcoverShelf.create.mockResolvedValueOnce({
id: 'new-shelf-4',
name: 'Currently Reading',
listId: 'status-2',
lastSyncAt: null,
createdAt: new Date().toISOString(),
bookCount: 2,
coverUrls: null,
});
const { POST } = await import('@/app/api/user/hardcover-shelves/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
// The coverUrls stored should only include books with non-null coverUrl
expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
// 1 book has cover, 1 doesn't → only 1 stored
coverUrls: JSON.stringify([
{ coverUrl: 'https://example.com/dune.jpg', asin: null, title: 'Dune', author: 'Frank Herbert' },
]),
}),
})
);
});
});
+1
View File
@@ -47,6 +47,7 @@ export const createPrismaMock = () => ({
bookDateSwipe: createModelMock(), bookDateSwipe: createModelMock(),
goodreadsShelf: createModelMock(), goodreadsShelf: createModelMock(),
goodreadsBookMapping: createModelMock(), goodreadsBookMapping: createModelMock(),
hardcoverShelf: createModelMock(),
$queryRaw: vi.fn(), $queryRaw: vi.fn(),
$disconnect: vi.fn(), $disconnect: vi.fn(),
}); });
+4 -4
View File
@@ -21,7 +21,7 @@ const processorsMock = vi.hoisted(() => ({
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'), processSyncShelves: vi.fn().mockResolvedValue('ok'),
// Ebook processors // Ebook processors
processSearchEbook: vi.fn().mockResolvedValue('ok'), processSearchEbook: vi.fn().mockResolvedValue('ok'),
processStartDirectDownload: vi.fn().mockResolvedValue('ok'), processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
@@ -116,8 +116,8 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
})); }));
vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({ vi.mock('@/lib/processors/sync-shelves.processor', () => ({
processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, processSyncShelves: processorsMock.processSyncShelves,
})); }));
// Ebook processors // Ebook processors
@@ -564,7 +564,7 @@ describe('JobQueueService', () => {
expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled();
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled(); expect(processorsMock.processSyncShelves).toHaveBeenCalled();
}); });
it('returns repeatable jobs from the queue', async () => { it('returns repeatable jobs from the queue', async () => {
+6 -3
View File
@@ -18,7 +18,8 @@ const jobQueueMock = vi.hoisted(() => ({
addRetryFailedImportsJob: vi.fn(), addRetryFailedImportsJob: vi.fn(),
addCleanupSeededTorrentsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(),
addMonitorRssFeedsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(),
addSyncGoodreadsShelvesJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(),
addSyncShelvesJob: vi.fn(),
})); }));
const configServiceMock = vi.hoisted(() => ({ const configServiceMock = vi.hoisted(() => ({
@@ -63,7 +64,9 @@ describe('SchedulerService', () => {
prismaMock.scheduledJob.findFirst.mockResolvedValue(null); prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
prismaMock.scheduledJob.create.mockResolvedValue({}); prismaMock.scheduledJob.create.mockResolvedValue({});
prismaMock.scheduledJob.findMany prismaMock.scheduledJob.findMany
.mockResolvedValueOnce([]) // cleanupDeprecatedJobs
.mockResolvedValueOnce([ .mockResolvedValueOnce([
// scheduleAllJobs
{ {
id: 'job-1', id: 'job-1',
name: 'Audible Data Refresh', name: 'Audible Data Refresh',
@@ -72,7 +75,7 @@ describe('SchedulerService', () => {
enabled: true, enabled: true,
}, },
]) ])
.mockResolvedValueOnce([]); .mockResolvedValue([]); // triggerOverdueJobs
const { SchedulerService } = await import('@/lib/services/scheduler.service'); const { SchedulerService } = await import('@/lib/services/scheduler.service');
const service = new SchedulerService(); const service = new SchedulerService();
@@ -289,7 +292,7 @@ describe('SchedulerService', () => {
['retry_failed_imports', 'addRetryFailedImportsJob'], ['retry_failed_imports', 'addRetryFailedImportsJob'],
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
['monitor_rss_feeds', 'addMonitorRssFeedsJob'], ['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'], ['sync_reading_shelves', 'addSyncShelvesJob'],
])('triggers %s jobs with job queue', async (type, queueMethod) => { ])('triggers %s jobs with job queue', async (type, queueMethod) => {
prismaMock.scheduledJob.findUnique.mockResolvedValue({ prismaMock.scheduledJob.findUnique.mockResolvedValue({
id: 'job-type', id: 'job-type',