mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Hardcover API support
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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 { 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();
|
||||
const { listId, apiToken } = AddShelfSchema.parse(body);
|
||||
|
||||
// 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 shelf = await prisma.hardcoverShelf.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
name: listName,
|
||||
listId,
|
||||
apiToken,
|
||||
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.addSyncHardcoverShelvesJob(undefined, shelf.id, 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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+81
-16
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||
import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
@@ -19,7 +20,11 @@ const statConfig = [
|
||||
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
|
||||
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
|
||||
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
|
||||
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
|
||||
{
|
||||
key: 'cancelled',
|
||||
label: 'Cancelled',
|
||||
color: 'text-gray-400 dark:text-gray-500',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StatKey = (typeof statConfig)[number]['key'];
|
||||
@@ -30,25 +35,45 @@ export default function ProfilePage() {
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!requests.length) {
|
||||
return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
|
||||
return {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
active: 0,
|
||||
waiting: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
total: requests.length,
|
||||
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
|
||||
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
|
||||
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
|
||||
completed: requests.filter((r: any) =>
|
||||
['available', 'downloaded'].includes(r.status),
|
||||
).length,
|
||||
active: requests.filter((r: any) =>
|
||||
['pending', 'searching', 'downloading', 'processing'].includes(
|
||||
r.status,
|
||||
),
|
||||
).length,
|
||||
waiting: requests.filter((r: any) =>
|
||||
['awaiting_search', 'awaiting_import'].includes(r.status),
|
||||
).length,
|
||||
failed: requests.filter((r: any) => r.status === 'failed').length,
|
||||
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
||||
};
|
||||
}, [requests]);
|
||||
|
||||
const activeDownloads = useMemo(() => {
|
||||
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
|
||||
return requests.filter((r: any) =>
|
||||
['downloading', 'processing'].includes(r.status),
|
||||
);
|
||||
}, [requests]);
|
||||
|
||||
const recentRequests = useMemo(() => {
|
||||
return [...requests]
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [requests]);
|
||||
|
||||
@@ -58,8 +83,18 @@ export default function ProfilePage() {
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
@@ -113,7 +148,7 @@ export default function ProfilePage() {
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
{user.role === 'admin' ? 'Administrator' : 'User'}
|
||||
@@ -128,7 +163,12 @@ export default function ProfilePage() {
|
||||
key={stat.key}
|
||||
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl sm:text-3xl font-bold tabular-nums',
|
||||
stat.color,
|
||||
)}
|
||||
>
|
||||
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
|
||||
@@ -142,6 +182,9 @@ export default function ProfilePage() {
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
|
||||
{/* Hardcover Lists */}
|
||||
<HardcoverShelvesSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
<section>
|
||||
@@ -158,7 +201,11 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{activeDownloads.map((request: any) => (
|
||||
<RequestCard key={request.id} request={request} showActions={false} />
|
||||
<RequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -201,7 +248,11 @@ export default function ProfilePage() {
|
||||
) : recentRequests.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{recentRequests.map((request: any) => (
|
||||
<RequestCard key={request.id} request={request} showActions={false} />
|
||||
<RequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -213,7 +264,11 @@ export default function ProfilePage() {
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
|
||||
No requests yet
|
||||
@@ -225,8 +280,18 @@ export default function ProfilePage() {
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user