From 338331d00639b0d52aa3438dd10b4464abc6cbe1 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 10:11:19 -0500 Subject: [PATCH] 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. --- documentation/TABLEOFCONTENTS.md | 12 + .../backend/services/goodreads-sync.md | 75 +++ .../backend/services/hardcover-sync.md | 66 ++ .../migration.sql | 49 ++ .../migration.sql | 41 ++ prisma/schema.prisma | 53 +- .../api/user/hardcover-shelves/[id]/route.ts | 48 +- src/app/api/user/hardcover-shelves/route.ts | 25 +- src/app/api/user/shelves/route.ts | 28 +- src/components/profile/ShelvesSection.tsx | 6 +- src/components/ui/AddShelfModal.tsx | 226 ++----- src/components/ui/HardcoverForm.tsx | 318 +++++++++ src/components/ui/ManageShelfModal.tsx | 21 +- src/lib/hooks/createShelfHooks.ts | 172 +++++ src/lib/hooks/useGoodreadsShelves.ts | 150 +---- src/lib/hooks/useHardcoverShelves.ts | 158 +---- src/lib/services/goodreads-sync.service.ts | 370 +++-------- src/lib/services/hardcover-api.service.ts | 263 ++++++++ src/lib/services/hardcover-sync.service.ts | 610 ++---------------- src/lib/services/shelf-sync-core.service.ts | 274 ++++++++ src/lib/utils/shelf-helpers.ts | 36 ++ tests/helpers/prisma.ts | 2 +- tests/services/scheduler.service.test.ts | 1 - 23 files changed, 1613 insertions(+), 1391 deletions(-) create mode 100644 documentation/backend/services/goodreads-sync.md create mode 100644 documentation/backend/services/hardcover-sync.md create mode 100644 prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql create mode 100644 prisma/migrations/20260304000000_unify_book_mappings/migration.sql create mode 100644 src/components/ui/HardcoverForm.tsx create mode 100644 src/lib/hooks/createShelfHooks.ts create mode 100644 src/lib/services/hardcover-api.service.ts create mode 100644 src/lib/services/shelf-sync-core.service.ts create mode 100644 src/lib/utils/shelf-helpers.ts diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0d7fb76..7c6167d 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -32,6 +32,14 @@ - **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) - **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md) +## Reading Shelves (Goodreads, Hardcover) +- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md) +- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core) +- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory) +- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider) + ## Audible Integration - **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md) - **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md) @@ -150,3 +158,7 @@ **"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md) **"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) +**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md) +**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md) +**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider) +**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core) diff --git a/documentation/backend/services/goodreads-sync.md b/documentation/backend/services/goodreads-sync.md new file mode 100644 index 0000000..ddfa9c0 --- /dev/null +++ b/documentation/backend/services/goodreads-sync.md @@ -0,0 +1,75 @@ +# Goodreads & Shelf Sync + +**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture + +## Overview +Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers. + +## Architecture + +### Files +- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core +- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation) +- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing +- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations +- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes +- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes +- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape) +- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory) + +### Database Models +- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`) +- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups. + +## Goodreads RSS Feed +- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}` +- **Auth:** None required (public RSS) +- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url` + +## Shared Sync Core + +`shelf-sync-core.service.ts` contains all provider-agnostic sync logic: + +### Interface: `ShelfBook` +```typescript +{ bookId: string; title: string; author: string; coverUrl?: string } +``` + +### Function: `processShelfBooks()` +Accepts provider-agnostic book list + context, performs: +1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`) +2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info) +3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days) +4. **Request creation** — Calls `createRequestForUser()` for matched ASINs +5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs +6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls` + +### Constants +- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers) +- `NO_MATCH_RETRY_DAYS` = 7 + +### Hook Factory: `createShelfHooks(endpoint)` +Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint. + +## API Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves | +| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) | +| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) | +| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) | +| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` | + +## Adding a New Provider +1. Create Prisma shelf model + migration (BookMapping table is already shared) +2. Create API client service for the external data source +3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()` +4. Create API routes (or use a generic route handler) +5. Create hook file (~40 lines) using `createShelfHooks(endpoint)` +6. Add tab in `AddShelfModal` with provider-specific form fields + +## Related +- [Hardcover sync](hardcover-sync.md) +- [Background jobs](jobs.md) +- [Scheduler](scheduler.md) diff --git a/documentation/backend/services/hardcover-sync.md b/documentation/backend/services/hardcover-sync.md new file mode 100644 index 0000000..e066c27 --- /dev/null +++ b/documentation/backend/services/hardcover-sync.md @@ -0,0 +1,66 @@ +# Hardcover Shelf Sync + +**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation + +## Overview +Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically. + +## Architecture + +### Files +- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()` +- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core +- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation) +- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes +- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes +- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory) + +### Database Models +- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`) +- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`) + +## Hardcover API + +- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based) +- **Auth:** Bearer token in Authorization header +- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables + +### Query Strategies (custom lists) +| Input | Strategy | Query root | +|---|---|---| +| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` | +| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` | +| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` | + +### Status Lists +- Prefix: `status-{id}` (e.g., `status-1`) +- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }` +- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish + +## Sync Flow +1. Fetch shelves from DB (all or specific `shelfId`) +2. Decrypt API token (encryption service) +3. Fetch books from Hardcover GraphQL API +4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment) +5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`) + +## API Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers | +| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) | +| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) | +| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) | + +## Key Details +- **Token cleanup:** Strips `Bearer ` prefix if user pastes it +- **Duplicate check:** Unique constraint on `(userId, listId)` +- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups +- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle) +- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display + +## Related +- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core) +- [Background jobs](jobs.md) +- [Scheduler](scheduler.md) diff --git a/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql b/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql new file mode 100644 index 0000000..15af2a9 --- /dev/null +++ b/prisma/migrations/20260303200000_add_hardcover_shelves/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "hardcover_shelves" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "list_id" TEXT NOT NULL, + "api_token" TEXT NOT NULL, + "last_sync_at" TIMESTAMP(3), + "book_count" INTEGER, + "cover_urls" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "hardcover_book_mappings" ( + "id" TEXT NOT NULL, + "hardcover_book_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "audible_asin" TEXT, + "cover_url" TEXT, + "no_match" BOOLEAN NOT NULL DEFAULT false, + "last_search_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id"); + +-- CreateIndex +CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id"); + +-- CreateIndex +CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin"); + +-- AddForeignKey +ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260304000000_unify_book_mappings/migration.sql b/prisma/migrations/20260304000000_unify_book_mappings/migration.sql new file mode 100644 index 0000000..3dc99f9 --- /dev/null +++ b/prisma/migrations/20260304000000_unify_book_mappings/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "book_mappings" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "external_book_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "audible_asin" TEXT, + "cover_url" TEXT, + "no_match" BOOLEAN NOT NULL DEFAULT false, + "last_search_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id") +); + +-- Migrate data from goodreads_book_mappings +INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at") +SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at" +FROM "goodreads_book_mappings"; + +-- Migrate data from hardcover_book_mappings +INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at") +SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at" +FROM "hardcover_book_mappings"; + +-- DropTable +DROP TABLE "goodreads_book_mappings"; + +-- DropTable +DROP TABLE "hardcover_book_mappings"; + +-- CreateIndex +CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id"); + +-- CreateIndex +CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id"); + +-- CreateIndex +CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51f7634..a0cf3eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -518,26 +518,34 @@ model GoodreadsShelf { @@map("goodreads_shelves") } -model GoodreadsBookMapping { - id String @id @default(uuid()) - goodreadsBookId String @unique @map("goodreads_book_id") - 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") +// ============================================================================ +// UNIFIED BOOK MAPPING TABLE +// Global book-to-ASIN mapping cache shared across all shelf providers. +// Uses provider + externalBookId composite key for cross-provider dedup. +// ============================================================================ - @@index([goodreadsBookId]) +model BookMapping { + id String @id @default(uuid()) + provider String // "goodreads", "hardcover", etc. + externalBookId String @map("external_book_id") + 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") + + @@unique([provider, externalBookId]) + @@index([provider, externalBookId]) @@index([audibleAsin]) - @@map("goodreads_book_mappings") + @@map("book_mappings") } // ============================================================================ // HARDCOVER SYNC TABLES -// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache +// Per-user Hardcover list subscriptions // ============================================================================ model HardcoverShelf { @@ -560,23 +568,6 @@ model HardcoverShelf { @@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") -} - // ============================================================================ // WORKS TABLE // Cross-ASIN audiobook identity mapping — links multiple Audible ASINs diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index b7f94a5..438cbcb 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -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 diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 4390220..725870b 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -11,6 +11,7 @@ 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'; +import { processBooks } from '@/lib/utils/shelf-helpers'; const logger = RMABLogger.create('API.HardcoverShelves'); @@ -36,29 +37,7 @@ export async function GET(request: NextRequest) { }); 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; - return { - coverUrl: (obj.coverUrl as string) || '', - asin: (obj.asin as string) || null, - title: (obj.title as string) || '', - author: (obj.author as string) || '', - }; - }); - } - } + const books = processBooks(shelf.coverUrls); return { id: shelf.id, diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts index 93419df..f017a78 100644 --- a/src/app/api/user/shelves/route.ts +++ b/src/app/api/user/shelves/route.ts @@ -7,6 +7,7 @@ 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 { processBooks } from '@/lib/utils/shelf-helpers'; const logger = RMABLogger.create('API.Shelves'); @@ -32,33 +33,6 @@ export async function GET(request: NextRequest) { }), ]); - 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; - 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, diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index 7072270..5a9de8e 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -354,8 +354,9 @@ function ShelfCard({
@@ -147,97 +132,56 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { ? '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(''); - }} + onClick={() => { setProvider('hardcover'); setValidationError(''); }} > Hardcover
- {/* Visual header */} + {/* Visual Header */}
{provider === 'goodreads' ? ( <>
- Goodreads -
-
-

- Paste your Goodreads shelf RSS URL. Books will be - automatically requested. -

+ Goodreads
+

+ Paste your Goodreads shelf RSS URL. Books will be automatically requested. +

) : ( <>
- Hardcover -
-
-

- Provide your Hardcover API token and select the list you want - to sync. -

+ Hardcover
+

+ Connect a Hardcover reading list and books will be automatically requested as you add them. +

)}
- {/* Success alert */} + {/* Success Alert */} {success && (
- - + +
-

- {successMessage} -

+

{successMessage}

)} - {/* Error alert */} + {/* Error Alert */} {currentError && (
- - + +
-

- {currentError} -

+

{currentError}

)} @@ -249,113 +193,37 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { type="url" label="Goodreads RSS URL" value={rssUrl} - onChange={(e) => { - setRssUrl(e.target.value); - if (validationError) setValidationError(''); - }} + onChange={(e) => { setRssUrl(e.target.value); if (validationError) setValidationError(''); }} placeholder="https://www.goodreads.com/review/list_rss/..." error={validationError} disabled={isLoading || success} />

- Find it on Goodreads: My Books → select a shelf → RSS - link at the bottom of the page. + Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.

) : ( -
- { - setApiToken(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="eyJhb..." - disabled={isLoading || success} - /> - -
- -
- - -
-
- - {listType === 'status' ? ( -
- -
- ) : ( - { - setCustomListId(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="https://hardcover.app/@username/lists/..." - error={validationError} - disabled={isLoading || success} - /> - )} -
+ )}
- -
diff --git a/src/components/ui/HardcoverForm.tsx b/src/components/ui/HardcoverForm.tsx new file mode 100644 index 0000000..ff531e1 --- /dev/null +++ b/src/components/ui/HardcoverForm.tsx @@ -0,0 +1,318 @@ +/** + * Component: Hardcover Shelf Form + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React from 'react'; +import { Input } from './Input'; + +// --------------------------------------------------------------------------- +// Status option definitions +// --------------------------------------------------------------------------- + +const STATUS_OPTIONS = [ + { + id: '1', + label: 'Want to Read', + description: 'Books saved to read later', + icon: ( + + + + ), + }, + { + id: '2', + label: 'Currently Reading', + description: 'Books actively being read', + icon: ( + + + + ), + }, + { + id: '3', + label: 'Read', + description: 'Books already finished', + icon: ( + + + + ), + }, + { + id: '4', + label: 'Did Not Finish', + description: 'Books started but set aside', + icon: ( + + + + ), + }, +] as const; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HardcoverFormProps { + apiToken: string; + setApiToken: (v: string) => void; + listType: 'status' | 'custom'; + setListType: (v: 'status' | 'custom') => void; + statusId: string; + setStatusId: (v: string) => void; + customListId: string; + setCustomListId: (v: string) => void; + validationError: string; + setValidationError: (v: string) => void; + isLoading: boolean; + success: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function HardcoverForm({ + apiToken, setApiToken, + listType, setListType, + statusId, setStatusId, + customListId, setCustomListId, + validationError, setValidationError, + isLoading, success, +}: HardcoverFormProps) { + const disabled = isLoading || success; + const isTokenError = validationError === 'Hardcover API Token is required'; + const isListError = !isTokenError && !!validationError; + + return ( +
+ + {/* API Token */} +
+
+ + + Get your token + + + + +
+ { + setApiToken(e.target.value); + if (isTokenError) setValidationError(''); + }} + placeholder="Paste your Hardcover API token" + disabled={disabled} + className={[ + 'block w-full rounded-lg border px-4 py-2 text-sm transition-colors', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60', + 'disabled:opacity-50 disabled:cursor-not-allowed', + 'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white', + 'placeholder-gray-400 dark:placeholder-gray-500', + isTokenError + ? 'border-red-400 dark:border-red-500' + : 'border-gray-200 dark:border-gray-700', + ].join(' ')} + /> + {isTokenError && ( +

{validationError}

+ )} +

+ Found under{' '} + Settings → API + {' '}on hardcover.app. Stored securely and never shared. +

+
+ + {/* Divider */} +
+ + {/* List Type Selection */} +
+
+

+ Which list should we watch? +

+

+ Choose a reading status or one of your custom lists. +

+
+ +
+ setListType('status')} + disabled={disabled} + icon={ + + + + } + title="Reading Status" + subtitle="Want to Read, Reading, Read, etc." + /> + setListType('custom')} + disabled={disabled} + icon={ + + + + } + title="Custom List" + subtitle="A list you created on Hardcover" + /> +
+
+ + {/* Status picker or Custom list input */} + {listType === 'status' ? ( +
+

Status to sync

+
+ {STATUS_OPTIONS.map((opt) => ( + setStatusId(opt.id)} + disabled={disabled} + /> + ))} +
+
+ ) : ( +
+ { + setCustomListId(e.target.value); + if (isListError) setValidationError(''); + }} + placeholder="https://hardcover.app/@username/lists/..." + error={isListError ? validationError : ''} + disabled={disabled} + /> +

+ Paste the list URL from Hardcover, or enter just the slug (e.g.{' '} + my-audiobooks + ) or a numeric ID. +

+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function ListTypeCard({ + active, onClick, disabled, icon, title, subtitle, +}: { + active: boolean; + onClick: () => void; + disabled: boolean; + icon: React.ReactNode; + title: string; + subtitle: string; +}) { + return ( + + ); +} + +function StatusRow({ + opt, selected, onSelect, disabled, +}: { + opt: typeof STATUS_OPTIONS[number]; + selected: boolean; + onSelect: () => void; + disabled: boolean; +}) { + return ( + + ); +} diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx index e46fa09..7dce0a6 100644 --- a/src/components/ui/ManageShelfModal.tsx +++ b/src/components/ui/ManageShelfModal.tsx @@ -1,3 +1,8 @@ +/** + * Component: Manage Shelf Modal + * Documentation: documentation/frontend/components.md + */ + 'use client'; import React, { useState } from 'react'; @@ -18,8 +23,8 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro 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(); + const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf(); // Reset form when shelf changes React.useEffect(() => { @@ -33,6 +38,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro if (!shelf) return null; const isUpdating = isUpdatingGoodreads || isUpdatingHardcover; + const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -58,6 +64,17 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro return (
+ {currentError && ( +
+
+ + + +
+

{currentError}

+
+ )} +
{isGoodreads ? (
diff --git a/src/lib/hooks/createShelfHooks.ts b/src/lib/hooks/createShelfHooks.ts new file mode 100644 index 0000000..d66643e --- /dev/null +++ b/src/lib/hooks/createShelfHooks.ts @@ -0,0 +1,172 @@ +/** + * Component: Shelf Hook Factory + * Documentation: documentation/frontend/components.md + * + * Generic hook factory for shelf CRUD operations. Each provider (Goodreads, + * Hardcover, etc.) calls this with its API endpoint to get fully typed hooks + * without duplicating the SWR/fetch/mutate boilerplate. + */ + +'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; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +/** + * Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint. + */ +function revalidate(endpoint: string) { + mutate((key) => typeof key === 'string' && key.includes(endpoint)); + mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves')); +} + +/** + * Creates a set of hooks for a shelf provider endpoint. + * + * Returns: + * - useList: SWR-based hook to list shelves + * - useAdd: Hook returning { addShelf(body), isLoading, error } + * - useDelete: Hook returning { deleteShelf(id), isLoading, error } + * - useUpdate: Hook returning { updateShelf(id, body), isLoading, error } + */ +export function createShelfHooks(endpoint: string) { + function useList() { + const { accessToken } = useAuth(); + const key = accessToken ? endpoint : null; + + const { data, error, isLoading } = useSWR(key, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as TShelf[], + isLoading, + error, + }; + } + + function useAdd() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addShelf = async (body: Record) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to add shelf'); + } + + revalidate(endpoint); + return data.shelf as TShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addShelf, isLoading, error }; + } + + function useDelete() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteShelf = async (shelfId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`${endpoint}/${shelfId}`, { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to remove shelf'); + } + + revalidate(endpoint); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { deleteShelf, isLoading, error }; + } + + function useUpdate() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async (shelfId: string, body: Record) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`${endpoint}/${shelfId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to update shelf'); + } + + revalidate(endpoint); + return data.shelf as TShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { updateShelf, isLoading, error }; + } + + return { useList, useAdd, useDelete, useUpdate }; +} diff --git a/src/lib/hooks/useGoodreadsShelves.ts b/src/lib/hooks/useGoodreadsShelves.ts index d67477b..4b98728 100644 --- a/src/lib/hooks/useGoodreadsShelves.ts +++ b/src/lib/hooks/useGoodreadsShelves.ts @@ -5,17 +5,9 @@ 'use client'; -import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; -import { useAuth } from '@/contexts/AuthContext'; -import { fetchWithAuth } from '@/lib/utils/api'; +import { createShelfHooks, ShelfBook } from './createShelfHooks'; -export interface ShelfBook { - coverUrl: string; - asin: string | null; - title: string; - author: string; -} +export type { ShelfBook }; export interface GoodreadsShelf { id: string; @@ -27,150 +19,28 @@ export interface GoodreadsShelf { books: ShelfBook[]; } -const fetcher = (url: string) => - fetchWithAuth(url).then((res) => res.json()); +const { useList, useAdd, useDelete, useUpdate } = + createShelfHooks('/api/user/goodreads-shelves'); -export function useGoodreadsShelves() { - const { accessToken } = useAuth(); - - const endpoint = accessToken ? '/api/user/goodreads-shelves' : null; - - const { data, error, isLoading } = useSWR( - endpoint, - fetcher, - { refreshInterval: 30000 } - ); - - return { - shelves: (data?.shelves || []) as GoodreadsShelf[], - isLoading, - error, - }; -} +export const useGoodreadsShelves = useList; export function useAddGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { addShelf: addGeneric, isLoading, error } = useAdd(); const addShelf = async (rssUrl: string) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth('/api/user/goodreads-shelves', { - method: 'POST', - 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 add shelf'); - } - - // Revalidate shelves list - mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-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 addGeneric({ rssUrl }); }; return { addShelf, isLoading, error }; } -export function useDeleteGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const deleteShelf = async (shelfId: string) => { - if (!accessToken) throw new Error('Not authenticated'); - - setIsLoading(true); - setError(null); - - try { - const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, { - method: 'DELETE', - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || data.error || 'Failed to remove shelf'); - } - - // Revalidate shelves list - mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-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 const useDeleteGoodreadsShelf = useDelete; export function useUpdateGoodreadsShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); 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 updateGeneric(shelfId, { rssUrl }); }; return { updateShelf, isLoading, error }; diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts index f9a4bcc..b845917 100644 --- a/src/lib/hooks/useHardcoverShelves.ts +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -5,17 +5,9 @@ 'use client'; -import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; -import { useAuth } from '@/contexts/AuthContext'; -import { fetchWithAuth } from '@/lib/utils/api'; +import { createShelfHooks, ShelfBook } from './createShelfHooks'; -export interface ShelfBook { - coverUrl: string; - asin: string | null; - title: string; - author: string; -} +export type { ShelfBook }; export interface HardcoverShelf { id: string; @@ -27,161 +19,31 @@ export interface HardcoverShelf { books: ShelfBook[]; } -const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); +const { useList, useAdd, useDelete, useUpdate } = + createShelfHooks('/api/user/hardcover-shelves'); -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 const useHardcoverShelves = useList; export function useAddHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { addShelf: addGeneric, isLoading, error } = useAdd(); 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 addGeneric({ apiToken, listId }); }; return { addShelf, isLoading, error }; } -export function useDeleteHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(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 const useDeleteHardcoverShelf = useDelete; export function useUpdateHardcoverShelf() { - const { accessToken } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); 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 updateGeneric(shelfId, updates); }; return { updateShelf, isLoading, error }; diff --git a/src/lib/services/goodreads-sync.service.ts b/src/lib/services/goodreads-sync.service.ts index 8653435..37dc668 100644 --- a/src/lib/services/goodreads-sync.service.ts +++ b/src/lib/services/goodreads-sync.service.ts @@ -2,36 +2,29 @@ * Component: Goodreads Shelf Sync Service * Documentation: documentation/backend/services/goodreads-sync.md * - * Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs, - * and creates requests via the shared request-creator service. + * Fetches Goodreads shelf RSS feeds and delegates book processing + * to the shared shelf-sync-core service. */ import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; import { prisma } from '@/lib/db'; -import { getAudibleService } from '@/lib/integrations/audible.service'; -import { createRequestForUser } from '@/lib/services/request-creator.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { + ShelfBook, + ShelfSyncStats, + ShelfSyncOptions, + createEmptyStats, + resolveMaxLookups, + processShelfBooks, +} from '@/lib/services/shelf-sync-core.service'; const logger = RMABLogger.create('GoodreadsSync'); -/** 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; - -interface GoodreadsRssBook { - bookId: string; - title: string; - author: string; - coverUrl?: string; -} - /** * Parse a Goodreads RSS feed XML into structured book data. */ -function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } { +function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', @@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf'; - // Normalize items to array let items = channel.item; if (!items) return { shelfName, books: [] }; if (!Array.isArray(items)) items = [items]; - const books: GoodreadsRssBook[] = []; + const books: ShelfBook[] = []; for (const item of items) { const bookId = item.book_id?.toString(); if (!bookId) continue; const title = (item.title || '').toString().trim(); - const authorName = (item.author_name || '').toString().trim(); - // Goodreads RSS has book_image_url or book_medium_image_url + const author = (item.author_name || '').toString().trim(); const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined; - if (title && authorName) { - books.push({ bookId, title, author: authorName, coverUrl }); + if (title && author) { + books.push({ bookId, title, author, coverUrl }); } } return { shelfName, books }; } +/** Max items Goodreads returns per RSS page */ +const GOODREADS_PAGE_SIZE = 100; + +/** Safety cap to avoid infinite loops */ +const MAX_PAGES = 50; + /** * Fetch and validate a Goodreads RSS URL. - * Returns the parsed shelf name and books if valid. + * Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items. + * Deduplicates by bookId across pages. */ -export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> { - const response = await axios.get(rssUrl, { timeout: 15000 }); - return parseGoodreadsRss(response.data); +export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> { + const url = new URL(rssUrl); + url.searchParams.set('sort', 'title'); + + let shelfName = 'Goodreads Shelf'; + const seenIds = new Set(); + const allBooks: ShelfBook[] = []; + + for (let page = 1; page <= MAX_PAGES; page++) { + url.searchParams.set('page', page.toString()); + + const response = await axios.get(url.toString(), { timeout: 15000 }); + const parsed = parseGoodreadsRss(response.data); + + if (page === 1) { + shelfName = parsed.shelfName; + } + + for (const book of parsed.books) { + if (!seenIds.has(book.bookId)) { + seenIds.add(book.bookId); + allBooks.push(book); + } + } + + if (parsed.books.length < GOODREADS_PAGE_SIZE) break; + } + + return { shelfName, books: allBooks }; } -export interface GoodreadsSyncStats { - shelvesProcessed: number; - booksFound: number; - lookupsPerformed: number; - requestsCreated: number; - errors: number; -} - -export interface GoodreadsSyncOptions { - /** Process only this shelf ID (for immediate single-shelf sync) */ - shelfId?: string; - /** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */ - maxLookupsPerShelf?: number; -} +// Re-export types that downstream consumers expect +export type { ShelfSyncStats as GoodreadsSyncStats }; +export type { ShelfSyncOptions as GoodreadsSyncOptions }; /** * Process Goodreads shelves: fetch RSS, resolve ASINs, create requests. - * Called from the dedicated sync_goodreads_shelves processor. + * Called from the unified sync_reading_shelves processor. */ export async function processGoodreadsShelves( jobLogger?: ReturnType, - options: GoodreadsSyncOptions = {} -): Promise { + options: ShelfSyncOptions = {} +): Promise { const log = jobLogger || logger; - const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 }; - - const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; + const stats = createEmptyStats(); + const maxLookups = resolveMaxLookups(options); const whereClause = options.shelfId ? { id: options.shelfId } : {}; const shelves = await prisma.goodreadsShelf.findMany({ @@ -121,7 +133,32 @@ export async function processGoodreadsShelves( for (const shelf of shelves) { try { - await processShelf(shelf, stats, log, maxLookups); + log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`); + + let rssData: { shelfName: string; books: ShelfBook[] }; + try { + rssData = await fetchAndValidateRss(shelf.rssUrl); + } catch (error) { + log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`); + stats.errors++; + continue; + } + + log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`); + + const bookData = await processShelfBooks( + 'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups, + ); + + await prisma.goodreadsShelf.update({ + where: { id: shelf.id }, + data: { + lastSyncAt: new Date(), + bookCount: rssData.books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); + stats.shelvesProcessed++; } catch (error) { stats.errors++; @@ -132,238 +169,3 @@ export async function processGoodreadsShelves( log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`); return stats; } - -async function processShelf( - shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } }, - stats: GoodreadsSyncStats, - log: ReturnType | ReturnType, - maxLookups: number -) { - log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`); - - let rssData: { shelfName: string; books: GoodreadsRssBook[] }; - try { - rssData = await fetchAndValidateRss(shelf.rssUrl); - } catch (error) { - log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`); - return; - } - - const books = rssData.books; - stats.booksFound += books.length; - log.info(`Found ${books.length} books in shelf "${shelf.name}"`); - - let lookupsThisCycle = 0; - const unlimitedLookups = maxLookups === 0; - - for (const book of books) { - // Look up existing mapping - let mapping = await prisma.goodreadsBookMapping.findUnique({ - where: { goodreadsBookId: book.bookId }, - }); - - if (!mapping) { - // No mapping exists — perform Audible lookup if under cap - if (!unlimitedLookups && lookupsThisCycle >= maxLookups) { - continue; // Will be resolved in a future cycle - } - - mapping = await performAudibleLookup(book, log); - lookupsThisCycle++; - stats.lookupsPerformed++; - - // If lookup found an ASIN, fall through to create request immediately - if (!mapping?.audibleAsin) { - continue; - } - } - - // Mapping exists with noMatch — check if we should retry - 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 retry found an ASIN, fall through to create request - if (!mapping?.audibleAsin) { - continue; - } - } else { - continue; // Still no match, skip - } - } else { - continue; - } - } - - // Mapping has ASIN — try to create request - 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})`); - } - // If not success, it's already available/requested/duplicate — silently skip - } catch (error) { - log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - } - - // Collect enriched book data (coverUrl + ASIN) for display - const bookIds = books.map(b => b.bookId); - const mappings = bookIds.length > 0 - ? await prisma.goodreadsBookMapping.findMany({ - where: { goodreadsBookId: { in: bookIds } }, - select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true }, - }) - : []; - const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m])); - - // Look up AudibleCache records for high-quality cached cover URLs - 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); - // Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover - 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 => b !== null) - .slice(0, 8); - - // Update shelf metadata - await prisma.goodreadsShelf.update({ - where: { id: shelf.id }, - data: { - lastSyncAt: new Date(), - bookCount: books.length, - coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, - }, - }); -} - -async function performAudibleLookup( - book: GoodreadsRssBook, - log: ReturnType | ReturnType, - existingMappingId?: string -): Promise { - const audibleService = getAudibleService(); - - try { - // Try full Goodreads title first, then fall back to stripped title - // (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results) - 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})`); - - // Use clean Audible/Audnexus metadata instead of Goodreads data - // (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches) - 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.goodreadsBookMapping.update({ where: { id: existingMappingId }, data }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...data }, - }); - } - - // No match found - 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.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...noMatchData }, - }); - } catch (error) { - log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); - - // Still create/update mapping so we don't retry every cycle - const errorData = { - title: book.title, - author: book.author, - coverUrl: book.coverUrl || null, - noMatch: true, - lastSearchAt: new Date(), - }; - - if (existingMappingId) { - return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData }); - } - return prisma.goodreadsBookMapping.create({ - data: { goodreadsBookId: book.bookId, ...errorData }, - }); - } -} diff --git a/src/lib/services/hardcover-api.service.ts b/src/lib/services/hardcover-api.service.ts new file mode 100644 index 0000000..d8e4df8 --- /dev/null +++ b/src/lib/services/hardcover-api.service.ts @@ -0,0 +1,263 @@ +/** + * Component: Hardcover API Service + * Documentation: documentation/backend/services/hardcover-sync.md + * + * GraphQL queries and API communication with the Hardcover platform. + * Exports fetchHardcoverList for use by the sync orchestration layer. + */ + +import axios from 'axios'; + +const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; + +export 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 = { + 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 cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; + } else { + // Custom list query + // - URL with @username → query that user's lists by slug + // - Bare slug (no username) → query authenticated user's lists via `me` + // - Numeric ID → query globally (IDs are unique) + const isIntId = /^\d+$/.test(listIdStr); + let extractedSlug = listIdStr; + let extractedUsername: string | null = null; + + if (!isIntId) { + try { + if (listIdStr.includes('hardcover.app')) { + const url = new URL( + listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`, + ); + const parts = url.pathname.split('/').filter(Boolean); + // URL format: /@username/lists/slug + if (parts.length > 0) { + extractedSlug = parts[parts.length - 1]; + } + const userPart = parts.find((p) => p.startsWith('@')); + if (userPart) { + extractedUsername = userPart.slice(1); + } + } + } catch (e) { + // use extractedSlug as-is + } + } + + const listBookFields = ` + name + list_books(limit: 100, order_by: {id: desc}) { + book { + id title cached_image image { url } + contributions { author { name } } + } + } + `; + + // Numeric ID: globally unique, query the lists table directly + const queryById = ` + query GetListBooks($listId: Int!) { + lists(where: {id: {_eq: $listId}}, limit: 1) { + ${listBookFields} + } + } + `; + + // Slug with username: query through the users table to scope to that user + const queryByUserSlug = ` + query GetUserListBySlug($username: citext!, $slug: String!) { + users(where: {username: {_eq: $username}}, limit: 1) { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + ${listBookFields} + } + } + } + `; + + // Bare slug (no username): scope to the authenticated user via `me` + const queryByMySlug = ` + query GetMyListBySlug($slug: String!) { + me { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + ${listBookFields} + } + } + } + `; + + let activeQuery: string; + let variables: Record; + + if (isIntId) { + activeQuery = queryById; + variables = { listId: parseInt(listIdStr, 10) }; + } else if (extractedUsername) { + activeQuery = queryByUserSlug; + variables = { username: extractedUsername, slug: extractedSlug }; + } else { + activeQuery = queryByMySlug; + variables = { slug: extractedSlug }; + } + + 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}`, + ); + } + + // Extract lists array from the response based on which query was used + let listsData: any[]; + if (isIntId) { + listsData = response.data?.data?.lists || []; + } else if (extractedUsername) { + const users = response.data?.data?.users || []; + listsData = users[0]?.lists || []; + } else { + listsData = response.data?.data?.me?.[0]?.lists || []; + } + + if (listsData.length === 0) { + let identifier: string; + if (isIntId) { + identifier = `ID "${listIdStr}"`; + } else if (extractedUsername) { + identifier = `slug "${extractedSlug}" for user @${extractedUsername}`; + } else { + identifier = `slug "${extractedSlug}" in your Hardcover account`; + } + throw new Error(`Could not find a list with ${identifier}`); + } + + const listName = listsData[0].name || 'Hardcover List'; + const listBooks = listsData[0].list_books || []; + + 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 cachedImg = book.cached_image; + const coverUrl = + (typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) || + book.image?.url || + undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; + } +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index e644d14..edad091 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -2,279 +2,42 @@ * 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. + * Fetches Hardcover lists via GraphQL API and delegates book processing + * to the shared shelf-sync-core 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'; +import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service'; +import { + ShelfSyncStats, + ShelfSyncOptions, + createEmptyStats, + resolveMaxLookups, + processShelfBooks, +} from '@/lib/services/shelf-sync-core.service'; + +export { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; +export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service'; 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; -} +// Re-export types that downstream consumers expect +export type { ShelfSyncStats as HardcoverSyncStats }; +export type { ShelfSyncOptions as HardcoverSyncOptions }; /** - * 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. + * Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests. + * Called from the unified sync_reading_shelves processor. */ -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 = { - 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, - options: HardcoverSyncOptions = {}, -): Promise { + options: ShelfSyncOptions = {}, +): Promise { 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 stats = createEmptyStats(); + const maxLookups = resolveMaxLookups(options); const whereClause = options.shelfId ? { id: options.shelfId } : {}; const shelves = await prisma.hardcoverShelf.findMany({ @@ -297,7 +60,50 @@ export async function processHardcoverShelves( for (const shelf of shelves) { try { - await processShelf(shelf, stats, log, maxLookups); + log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`); + + const encryptionService = getEncryptionService(); + let decryptedToken = shelf.apiToken; + try { + 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'}`, + ); + stats.errors++; + continue; + } + + log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`); + + const bookData = await processShelfBooks( + 'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups, + ); + + 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: fetchedData.books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); + stats.shelvesProcessed++; } catch (error) { stats.errors++; @@ -312,287 +118,3 @@ export async function processHardcoverShelves( ); return stats; } - -async function processShelf( - shelf: { - id: string; - listId: string; - apiToken: string; - name: string; - user: { id: string; plexUsername: string }; - }, - stats: HardcoverSyncStats, - log: - | ReturnType - | ReturnType, - 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 => 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 - | ReturnType, - existingMappingId?: string, -): Promise { - 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 }, - }); - } -} diff --git a/src/lib/services/shelf-sync-core.service.ts b/src/lib/services/shelf-sync-core.service.ts new file mode 100644 index 0000000..69db7c8 --- /dev/null +++ b/src/lib/services/shelf-sync-core.service.ts @@ -0,0 +1,274 @@ +/** + * Component: Shelf Sync Core Service + * Documentation: documentation/backend/services/goodreads-sync.md + * + * Shared logic for all shelf providers: Audible lookup, noMatch retry, + * request creation, cover enrichment, and shelf metadata updates. + * Provider-specific services (Goodreads, Hardcover) call into this core. + */ + +import { prisma } from '@/lib/db'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { RMABLogger } from '@/lib/utils/logger'; +import { BookMapping } from '@/generated/prisma'; + +/** 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; + +/** Provider-agnostic book from any shelf source */ +export interface ShelfBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** Sync stats shared across all providers */ +export interface ShelfSyncStats { + shelvesProcessed: number; + booksFound: number; + lookupsPerformed: number; + requestsCreated: number; + errors: number; +} + +/** Common sync options */ +export interface ShelfSyncOptions { + shelfId?: string; + maxLookupsPerShelf?: number; +} + +type LoggerType = ReturnType | ReturnType; + +export function createEmptyStats(): ShelfSyncStats { + return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 }; +} + +export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void { + target.shelvesProcessed += source.shelvesProcessed; + target.booksFound += source.booksFound; + target.lookupsPerformed += source.lookupsPerformed; + target.requestsCreated += source.requestsCreated; + target.errors += source.errors; +} + +export function resolveMaxLookups(options: ShelfSyncOptions): number { + return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF; +} + +/** + * Process a list of books from any provider: resolve to ASINs, create requests, + * enrich covers, and return book data for shelf metadata. + */ +export async function processShelfBooks( + provider: string, + books: ShelfBook[], + userId: string, + shelfId: string, + stats: ShelfSyncStats, + log: LoggerType, + maxLookups: number, +): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> { + stats.booksFound += books.length; + + let lookupsThisCycle = 0; + const unlimitedLookups = maxLookups === 0; + + for (const book of books) { + let mapping = await prisma.bookMapping.findUnique({ + where: { provider_externalBookId: { provider, externalBookId: book.bookId } }, + }); + + if (!mapping) { + if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue; + + mapping = await performAudibleLookup(provider, 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(provider, book, log, mapping.id); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } else { + continue; + } + } else { + continue; + } + } + + if (mapping.audibleAsin) { + try { + const result = await createRequestForUser(userId, { + 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'}`); + } + } + } + + return enrichBookCovers(provider, books); +} + +/** + * Enrich book list with cached cover URLs from AudibleCache. + * Returns up to 8 books with the best available cover URL. + */ +async function enrichBookCovers( + provider: string, + books: ShelfBook[], +): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> { + const bookIds = books.map(b => b.bookId); + const mappings = bookIds.length > 0 + ? await prisma.bookMapping.findMany({ + where: { provider, externalBookId: { in: bookIds } }, + select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true }, + }) + : []; + const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, 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; + }) + ); + + return 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 => b !== null) + .slice(0, 8); +} + +/** + * Search Audible for a book, persist the result to the unified BookMapping table. + */ +async function performAudibleLookup( + provider: string, + book: ShelfBook, + log: LoggerType, + existingMappingId?: string, +): Promise { + 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.bookMapping.update({ where: { id: existingMappingId }, data }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: 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.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: 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.bookMapping.update({ where: { id: existingMappingId }, data: errorData }); + } + return prisma.bookMapping.create({ + data: { provider, externalBookId: book.bookId, ...errorData }, + }); + } +} diff --git a/src/lib/utils/shelf-helpers.ts b/src/lib/utils/shelf-helpers.ts new file mode 100644 index 0000000..1b82528 --- /dev/null +++ b/src/lib/utils/shelf-helpers.ts @@ -0,0 +1,36 @@ +/** + * Component: Shelf Helpers + * Documentation: documentation/frontend/components.md + */ + +/** + * Parse a JSON string of cover/book data into a typed array. + * Returns an empty array on parse failure (graceful degradation). + */ +export function processBooks( + coverUrls: string | null, +): { coverUrl: string; asin: string | null; title: string; author: string }[] { + if (!coverUrls) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(coverUrls); + } catch { + return []; + } + + if (!Array.isArray(parsed)) return []; + + return parsed.map((item: unknown) => { + if (typeof item === 'string') { + return { coverUrl: item, asin: null, title: '', author: '' }; + } + const obj = item as Record; + return { + coverUrl: (obj.coverUrl as string) || '', + asin: (obj.asin as string) || null, + title: (obj.title as string) || '', + author: (obj.author as string) || '', + }; + }); +} diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 90cd6e2..cc8989f 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -46,7 +46,7 @@ export const createPrismaMock = () => ({ bookDateRecommendation: createModelMock(), bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), - goodreadsBookMapping: createModelMock(), + bookMapping: createModelMock(), hardcoverShelf: createModelMock(), work: createModelMock(), workAsin: createModelMock(), diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index 8ff7adb..b294e81 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,7 +18,6 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), - addMonitorRssFeedsJob: vi.fn(), addSyncShelvesJob: vi.fn(), }));