Add Hardcover shelf sync & unify book mappings

Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
This commit is contained in:
kikootwo
2026-03-04 10:11:19 -05:00
parent 6ca2e964e8
commit 338331d006
23 changed files with 1613 additions and 1391 deletions
@@ -9,6 +9,7 @@ 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 { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
import { z } from 'zod';
const logger = RMABLogger.create('API.HardcoverShelves');
@@ -90,21 +91,50 @@ export async function PATCH(
const body = await request.json();
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
const updateData: any = {};
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
let needsResync = false;
if (listId && listId !== shelf.listId) {
updateData.listId = listId;
needsResync = true;
}
let cleanedToken: string | undefined;
if (apiToken && apiToken.trim() !== '') {
const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
? apiToken.trim().slice(7).trim()
: apiToken.trim();
}
const newListId = (listId && listId !== shelf.listId) ? listId : undefined;
// Validate token/listId by fetching the list before saving
if (cleanedToken || newListId) {
const encryptionService = getEncryptionService();
updateData.apiToken = encryptionService.encrypt(cleanedToken);
needsResync = true;
const tokenToTest = cleanedToken || (() => {
try {
return encryptionService.isEncryptedFormat(shelf.apiToken)
? encryptionService.decrypt(shelf.apiToken)
: shelf.apiToken;
} catch { return shelf.apiToken; }
})();
const listIdToTest = newListId || shelf.listId;
try {
await fetchHardcoverList(tokenToTest, listIdToTest);
} 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 },
);
}
if (newListId) {
updateData.listId = newListId;
needsResync = true;
}
if (cleanedToken) {
updateData.apiToken = encryptionService.encrypt(cleanedToken);
needsResync = true;
}
}
// If we are forcing a resync due to a change, clear metadata