Refactor shelves UI and jobs

This commit is contained in:
Rob Walsh
2026-02-27 15:46:10 -07:00
parent cfe780c6f0
commit 41d45d1210
12 changed files with 511 additions and 619 deletions
+71 -33
View File
@@ -16,10 +16,13 @@ const logger = RMABLogger.create('API.GoodreadsShelves');
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
const AddShelfSchema = z.object({
rssUrl: z.string().url().refine(
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
rssUrl: z
.string()
.url()
.refine((url) => GOODREADS_RSS_PATTERN.test(url), {
message:
'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
}),
});
/**
@@ -40,7 +43,12 @@ export async function GET(request: NextRequest) {
const shelvesWithMeta = shelves.map((shelf) => {
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
let books: {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
@@ -72,8 +80,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} 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 });
logger.error('Failed to list shelves', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to list shelves' },
{ status: 500 },
);
}
});
}
@@ -99,30 +112,43 @@ export async function POST(request: NextRequest) {
if (existing) {
return NextResponse.json(
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
{ status: 409 }
{
error: 'DuplicateShelf',
message: 'You have already added this shelf',
},
{ status: 409 },
);
}
// Validate by fetching the RSS feed
let shelfName: string;
let bookCount: number;
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
let initialBooks: {
coverUrl: string;
asin: null;
title: string;
author: string;
}[] = [];
try {
const rssData = await fetchAndValidateRss(rssUrl);
shelfName = rssData.shelfName;
bookCount = rssData.books.length;
initialBooks = rssData.books
.filter(b => b.coverUrl)
.filter((b) => b.coverUrl)
.slice(0, 8)
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
.map((b) => ({
coverUrl: b.coverUrl!,
asin: null,
title: b.title,
author: b.author,
}));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidRSS',
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -132,43 +158,55 @@ export async function POST(request: NextRequest) {
name: shelfName,
rssUrl,
bookCount,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
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.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
logger.info(
`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`,
);
} 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),
});
}
return NextResponse.json({
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
return NextResponse.json(
{
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
},
bookCount,
}, { status: 201 });
{ status: 201 },
);
} catch (error) {
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
logger.error('Failed to add shelf', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
{ status: 400 },
);
}
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
return NextResponse.json(
{ error: 'Failed to add shelf' },
{ status: 500 },
);
}
});
}
+1 -1
View File
@@ -159,7 +159,7 @@ export async function POST(request: NextRequest) {
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncHardcoverShelvesJob(undefined, shelf.id, 0);
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
logger.info(
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
);
+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 },
);
}
});
}