diff --git a/.gitignore b/.gitignore index e49ac7f..a834ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # IDE .idea +.vscode # Dependencies /node_modules @@ -55,4 +56,4 @@ next-env.d.ts /test-media /test-data /bookdrop -dockerfile.patch \ No newline at end of file +dockerfile.patch 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/package-lock.json b/package-lock.json index f0a3dbe..cc16af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "dependencies": { "@heroicons/react": "^2.2.0", "@prisma/client": "^6.19.0", @@ -299,7 +299,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -309,7 +309,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -343,7 +343,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -403,7 +403,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", 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 174d882..b9daf8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,7 @@ model User { bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] goodreadsShelves GoodreadsShelf[] + hardcoverShelves HardcoverShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") createdApiTokens ApiToken[] @relation("CreatedApiTokens") @@ -547,21 +548,54 @@ 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 +// ============================================================================ + +model HardcoverShelf { + id String @id @default(uuid()) + userId String @map("user_id") + name String // Extracted from Hardcover API list name or status + listId String @map("list_id") // Hardcover List ID or Status ID + apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api + lastSyncAt DateTime? @map("last_sync_at") + bookCount Int? @map("book_count") + coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, listId]) + @@index([userId]) + @@map("hardcover_shelves") } // ============================================================================ diff --git a/public/goodreads-icon.png b/public/goodreads-icon.png new file mode 100644 index 0000000..cbfa21c Binary files /dev/null and b/public/goodreads-icon.png differ diff --git a/public/hardcover-icon.svg b/public/hardcover-icon.svg new file mode 100644 index 0000000..6ca99f3 --- /dev/null +++ b/public/hardcover-icon.svg @@ -0,0 +1 @@ + diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts index ed072f1..7f5a226 100644 --- a/src/app/api/user/goodreads-shelves/[id]/route.ts +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; const logger = RMABLogger.create('API.GoodreadsShelves'); +const UpdateGoodreadsSchema = z.object({ + rssUrl: z.string().url('Must be a valid URL'), +}); + /** * DELETE /api/user/goodreads-shelves/[id] * Remove a Goodreads shelf subscription (ownership check) @@ -48,3 +54,57 @@ export async function DELETE( } }); } + +/** + * PATCH /api/user/goodreads-shelves/[id] + * Update a Goodreads shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'Shelf not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { rssUrl } = UpdateGoodreadsSchema.parse(body); + + // Force re-fetch by clearing metadata + const updated = await prisma.goodreadsShelf.update({ + where: { id }, + data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 9736619..8626fc0 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -139,8 +139,8 @@ export async function POST(request: NextRequest) { // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0); - logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0); + logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`); } catch (error) { logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts new file mode 100644 index 0000000..b0a7916 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -0,0 +1,177 @@ +/** + * Component: Hardcover Shelf Delete Route + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; +import { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.HardcoverShelves'); + +const UpdateHardcoverSchema = z.object({ + listId: z.string().min(1, 'List ID is required').optional(), + apiToken: z.string().optional(), +}); + +/** + * DELETE /api/user/hardcover-shelves/[id] + * Remove a Hardcover shelf subscription (ownership check) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const shelf = await prisma.hardcoverShelf.findUnique({ + where: { id }, + }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + // Ownership check + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await prisma.hardcoverShelf.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to delete list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to delete list' }, + { status: 500 }, + ); + } + }); +} + +/** + * PATCH /api/user/hardcover-shelves/[id] + * Update a Hardcover shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { listId, apiToken } = UpdateHardcoverSchema.parse(body); + + const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {}; + let needsResync = false; + + let cleanedToken: string | undefined; + if (apiToken && apiToken.trim() !== '') { + 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(); + let tokenToTest = cleanedToken || shelf.apiToken; + if (!cleanedToken) { + try { + if (encryptionService.isEncryptedFormat(shelf.apiToken)) { + tokenToTest = encryptionService.decrypt(shelf.apiToken); + } + } catch { + // Decryption failed, fall back to raw token + } + } + 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 + if (needsResync) { + updateData.lastSyncAt = null; + updateData.bookCount = null; + updateData.coverUrls = null; + } + + const updated = await prisma.hardcoverShelf.update({ + where: { id }, + data: updateData, + }); + + if (needsResync) { + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to update list' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts new file mode 100644 index 0000000..56feb35 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -0,0 +1,195 @@ +/** + * Component: Hardcover Shelves API Routes + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { fetchHardcoverList } from '@/lib/services/hardcover-api.service'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; +import { processBooks } from '@/lib/utils/shelf-helpers'; + +const logger = RMABLogger.create('API.HardcoverShelves'); + +const AddShelfSchema = z.object({ + listId: z.string().min(1, { message: 'List ID is required' }), + apiToken: z.string().min(1, { message: 'API Token is required' }), +}); + +/** + * GET /api/user/hardcover-shelves + * List the current user's Hardcover lists with book counts and covers + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shelves = await prisma.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + const shelvesWithMeta = shelves.map((shelf) => { + const books = processBooks(shelf.coverUrls); + + return { + id: shelf.id, + name: shelf.name, + listId: shelf.listId, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount ?? null, + books, + }; + }); + + return NextResponse.json({ success: true, shelves: shelvesWithMeta }); + } catch (error) { + logger.error('Failed to list Hardcover lists', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list Hardcover lists' }, + { status: 500 }, + ); + } + }); +} + +/** + * POST /api/user/hardcover-shelves + * Add a new Hardcover list subscription + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + let { listId, apiToken } = AddShelfSchema.parse(body); + + // Clean up token in case user pasted "Bearer " prefix + apiToken = apiToken.trim(); + if (apiToken.toLowerCase().startsWith('bearer ')) { + apiToken = apiToken.slice(7).trim(); + } + + // Check for duplicate + const existing = await prisma.hardcoverShelf.findUnique({ + where: { userId_listId: { userId: req.user.id, listId } }, + }); + + if (existing) { + return NextResponse.json( + { + error: 'DuplicateShelf', + message: 'You have already added this list', + }, + { status: 409 }, + ); + } + + // Validate by fetching the Hardcover GraphQL feed + let listName: string; + let bookCount: number; + let initialBooks: { + coverUrl: string; + asin: null; + title: string; + author: string; + }[] = []; + try { + const fetchedData = await fetchHardcoverList(apiToken, listId); + listName = fetchedData.listName; + bookCount = fetchedData.books.length; + initialBooks = fetchedData.books + .filter((b) => b.coverUrl) + .slice(0, 8) + .map((b) => ({ + coverUrl: b.coverUrl!, + asin: null, + title: b.title, + author: b.author, + })); + } catch (error) { + return NextResponse.json( + { + error: 'InvalidHardcoverList', + message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const encryptionService = getEncryptionService(); + const encryptedToken = encryptionService.encrypt(apiToken); + + const shelf = await prisma.hardcoverShelf.create({ + data: { + userId: req.user.id, + name: listName, + listId, + apiToken: encryptedToken, + bookCount, + coverUrls: + initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, + }, + }); + + // Trigger immediate sync for this shelf (unlimited lookups, process all books) + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0); + logger.info( + `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, + ); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json( + { + success: true, + shelf: { + id: shelf.id, + name: shelf.name, + listId: shelf.listId, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount, + books: initialBooks, + }, + bookCount, + }, + { status: 201 }, + ); + } catch (error) { + logger.error('Failed to add Hardcover list', { + error: error instanceof Error ? error.message : String(error), + }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'ValidationError', details: error.errors }, + { status: 400 }, + ); + } + + return NextResponse.json( + { error: 'Failed to add Hardcover list' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts new file mode 100644 index 0000000..f017a78 --- /dev/null +++ b/src/app/api/user/shelves/route.ts @@ -0,0 +1,73 @@ +/** + * Component: Combined Shelves API Routes + * Documentation: documentation/backend/services/goodreads-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { processBooks } from '@/lib/utils/shelf-helpers'; + +const logger = RMABLogger.create('API.Shelves'); + +/** + * GET /api/user/shelves + * List the current user's shelves (Goodreads, Hardcover) with book counts and covers + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const [goodreads, hardcover] = await Promise.all([ + prisma.goodreadsShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + prisma.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const combined = [ + ...goodreads.map((s) => ({ + id: s.id, + type: 'goodreads', + name: s.name, + sourceId: s.rssUrl, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ...hardcover.map((s) => ({ + id: s.id, + type: 'hardcover', + name: s.name, + sourceId: s.listId, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return NextResponse.json({ success: true, shelves: combined }); + } catch (error) { + logger.error('Failed to list shelves', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list shelves' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index d1cef06..ec340d3 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,7 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard'; import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; -import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +import { ShelvesSection } from '@/components/profile/ShelvesSection'; import { ApiTokensSection } from '@/components/profile/ApiTokensSection'; import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection'; @@ -141,8 +141,8 @@ export default function ProfilePage() { - {/* Goodreads Shelves */} - + {/* Generic Shelves Section */} + {/* Watched Series */} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index eff6201..3623629 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -12,7 +12,6 @@ import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/Button'; import { VersionBadge } from '@/components/ui/VersionBadge'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; export function Header() { @@ -21,8 +20,8 @@ export function Header() { const [showMobileMenu, setShowMobileMenu] = useState(false); const [showBookDate, setShowBookDate] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); - const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false); - const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); + const { containerRef, dropdownRef, positionAbove, style } = + useSmartDropdownPosition(showUserMenu); // Check if user can change password (local users only) const canChangePassword = user?.authProvider === 'local'; @@ -44,16 +43,14 @@ export function Header() { const response = await fetch('/api/bookdate/config', { headers: { - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, }); const data = await response.json(); // Show BookDate to any user with verified and enabled configuration setShowBookDate( - data.config && - data.config.isVerified && - data.config.isEnabled + data.config && data.config.isVerified && data.config.isEnabled, ); } catch (error) { console.error('Failed to check BookDate config:', error); @@ -92,15 +89,6 @@ export function Header() { > Profile - {canChangePassword && ( @@ -327,19 +345,15 @@ export function Header() { {/* User menu dropdown (rendered via portal) */} - {typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)} + {typeof window !== 'undefined' && + userMenuDropdown && + createPortal(userMenuDropdown, document.body)} {/* Change Password Modal */} setShowChangePasswordModal(false)} /> - - {/* Add Goodreads Shelf Modal */} - setShowAddGoodreadsModal(false)} - /> ); } diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx similarity index 56% rename from src/components/profile/GoodreadsShelvesSection.tsx rename to src/components/profile/ShelvesSection.tsx index 0b8d5e5..5a9de8e 100644 --- a/src/components/profile/GoodreadsShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -1,16 +1,21 @@ /** - * Component: Goodreads Shelves Section (Profile Page) + * Component: Combined Shelves Section (Profile Page) * Documentation: documentation/frontend/components.md */ 'use client'; import React, { useState } from 'react'; -import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; +import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; +import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { AddShelfModal } from '@/components/ui/AddShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; +import { Modal } from '@/components/ui/Modal'; +import { ManageShelfModal } from '@/components/ui/ManageShelfModal'; +import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return 'Never'; @@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string { return `${diffDays}d ago`; } -export function GoodreadsShelvesSection() { - const { shelves, isLoading } = useGoodreadsShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf(); +export function ShelvesSection() { + const { shelves, isLoading } = useShelves(); + const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } = + useDeleteGoodreadsShelf(); + const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = + useDeleteHardcoverShelf(); const { squareCovers } = usePreferences(); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); - const [selectedAsin, setSelectedAsin] = useState(null); - const handleDelete = async (shelfId: string) => { + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [showAddShelf, setShowAddShelf] = useState(false); + const [selectedAsin, setSelectedAsin] = useState(null); + const [manageShelf, setManageShelf] = useState(null); + + const handleDelete = async (shelf: GenericShelf) => { try { - await deleteShelf(shelfId); + if (shelf.type === 'goodreads') { + await deleteGoodreads(shelf.id); + } else { + await deleteHardcover(shelf.id); + } setConfirmDeleteId(null); } catch { // Error handled by hook } }; + const isDeleting = isDeletingGoodreads || isDeletingHardcover; + return (
{/* Section Header */}
-
- - +
+ +

- Goodreads Shelves + Shelves

{!isLoading && shelves.length > 0 && (

- {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected + {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '} + connected

)}
- + {shelves.length > 0 && ( + + )}
{/* Content */} @@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() { squareCovers={squareCovers} isDeleting={isDeleting && confirmDeleteId === shelf.id} isConfirmingDelete={confirmDeleteId === shelf.id} - onDelete={() => handleDelete(shelf.id)} + onDelete={() => handleDelete(shelf)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} + onManage={() => setManageShelf(shelf)} onBookClick={(asin) => setSelectedAsin(asin)} /> ))}
) : ( - setShowAddModal(true)} /> + setShowAddShelf(true)} /> )} - setShowAddModal(false)} + {/* Modals */} + setShowAddShelf(false)} + /> + + setManageShelf(null)} + shelf={manageShelf} /> - {/* Audiobook Detail Modal (read-only) */} {selectedAsin && ( void }) { return (
-
- - +
+ +
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) { Connect your reading list

- Link a Goodreads shelf and we'll automatically request the audiobook for every book you add. + Link a Goodreads or Hardcover shelf and we'll automatically request the + audiobook for every book you add.

@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { key={i} className={cn( 'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', - squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' + squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]', )} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }} /> @@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { /* ─── Shelf Card ─── */ interface ShelfCardProps { - shelf: GoodreadsShelf; + shelf: GenericShelf; squareCovers: boolean; isDeleting: boolean; isConfirmingDelete: boolean; onDelete: () => void; onConfirmDelete: () => void; onCancelDelete: () => void; + onManage: () => void; onBookClick: (asin: string) => void; } @@ -197,20 +265,44 @@ function ShelfCard({ onDelete, onConfirmDelete, onCancelDelete, + onManage, onBookClick, }: ShelfCardProps) { const displayBooks = shelf.books.slice(0, 6); const hasCovers = displayBooks.length > 0; - const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length); + const remainingCount = Math.max( + 0, + (shelf.bookCount || 0) - displayBooks.length, + ); const isSyncing = !shelf.lastSyncAt; + const providerIcon = + shelf.type === 'goodreads' ? ( + Goodreads + ) : ( + Hardcover + ); + return (
{/* Top: Shelf info + actions */} -
+
-

- {shelf.name} +

+ {shelf.name} {providerIcon}

{shelf.bookCount != null && ( @@ -259,22 +351,60 @@ function ShelfCard({
) : ( - +
+ + +
)}
{/* Bottom: Stacked book covers */} {hasCovers ? ( - + ) : isSyncing ? (
{[...Array(3)].map((_, i) => ( @@ -282,7 +412,7 @@ function ShelfCard({ key={i} className={cn( 'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800', - squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]' + squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]', )} style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }} /> @@ -322,7 +452,7 @@ function CoverStack({ 'transition-all duration-300 ease-out', hoveredIndex === i && 'scale-[1.18] shadow-xl', coverSize, - book.asin ? 'cursor-pointer' : 'cursor-default' + book.asin ? 'cursor-pointer' : 'cursor-default', )} style={{ marginLeft: i > 0 ? '-16px' : 0, @@ -331,7 +461,11 @@ function CoverStack({ onMouseEnter={() => setHoveredIndex(i)} onMouseLeave={() => setHoveredIndex(null)} onClick={() => book.asin && onBookClick(book.asin)} - title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} + title={ + book.asin + ? `${book.title}${book.author ? ` by ${book.author}` : ''}` + : undefined + } > diff --git a/src/components/ui/AddGoodreadsShelfModal.tsx b/src/components/ui/AddGoodreadsShelfModal.tsx deleted file mode 100644 index dd0489b..0000000 --- a/src/components/ui/AddGoodreadsShelfModal.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Component: Add Goodreads Shelf Modal - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState } from 'react'; -import { Modal } from './Modal'; -import { Input } from './Input'; -import { Button } from './Button'; -import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; - -interface AddGoodreadsShelfModalProps { - isOpen: boolean; - onClose: () => void; -} - -const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; - -export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) { - const [rssUrl, setRssUrl] = useState(''); - const [validationError, setValidationError] = useState(''); - const [success, setSuccess] = useState(false); - const [successMessage, setSuccessMessage] = useState(''); - const { addShelf, isLoading, error } = useAddGoodreadsShelf(); - - const validateUrl = (url: string): boolean => { - if (!url.trim()) { - setValidationError('RSS URL is required'); - return false; - } - if (!GOODREADS_RSS_PATTERN.test(url)) { - setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)'); - return false; - } - setValidationError(''); - return true; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateUrl(rssUrl)) return; - - try { - const shelf = await addShelf(rssUrl); - setSuccess(true); - setSuccessMessage(`Added shelf "${shelf.name}" successfully!`); - setRssUrl(''); - - setTimeout(() => { - setSuccess(false); - onClose(); - }, 2000); - } catch { - // Error is handled by the hook - } - }; - - const handleClose = () => { - setRssUrl(''); - setValidationError(''); - setSuccess(false); - setSuccessMessage(''); - onClose(); - }; - - return ( - -
- {/* Visual header */} -
-
- - - -
-
-

- Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync. -

-
-
- - {/* Success alert */} - {success && ( -
-
- - - -
-

{successMessage}

-
- )} - - {/* Error alert */} - {error && ( -
-
- - - -
-

{error}

-
- )} - - {/* Form */} -
-
- { - 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. -

-
- -
- - -
-
-
-
- ); -} diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx new file mode 100644 index 0000000..537235f --- /dev/null +++ b/src/components/ui/AddShelfModal.tsx @@ -0,0 +1,230 @@ +/** + * Component: Add Shelf Modal + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { Input } from './Input'; +import { Button } from './Button'; +import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { HardcoverForm } from './HardcoverForm'; + +interface AddShelfModalProps { + isOpen: boolean; + onClose: () => void; +} + +const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; + +export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { + const [provider, setProvider] = useState<'goodreads' | 'hardcover'>('goodreads'); + + // Goodreads State + const [rssUrl, setRssUrl] = useState(''); + + // Hardcover State + const [apiToken, setApiToken] = useState(''); + const [listType, setListType] = useState<'status' | 'custom'>('status'); + const [statusId, setStatusId] = useState('1'); + const [customListId, setCustomListId] = useState(''); + + const [validationError, setValidationError] = useState(''); + const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const { addShelf: addGoodreads, isLoading: isGoodreadsLoading, error: goodreadsError } = useAddGoodreadsShelf(); + const { addShelf: addHardcover, isLoading: isHardcoverLoading, error: hardcoverError } = useAddHardcoverShelf(); + + const isLoading = isGoodreadsLoading || isHardcoverLoading; + const currentError = provider === 'goodreads' ? goodreadsError : hardcoverError; + + const validateInput = (): boolean => { + if (provider === 'goodreads') { + if (!rssUrl.trim()) { + setValidationError('RSS URL is required'); + return false; + } + if (!GOODREADS_RSS_PATTERN.test(rssUrl)) { + setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)'); + return false; + } + } else { + if (!apiToken.trim()) { + setValidationError('Hardcover API Token is required'); + return false; + } + if (listType === 'custom' && !customListId.trim()) { + setValidationError('Hardcover List URL or Slug is required'); + return false; + } + } + setValidationError(''); + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateInput()) return; + + try { + if (provider === 'goodreads') { + const shelf = await addGoodreads(rssUrl); + setSuccessMessage(`Added shelf "${shelf.name}" successfully!`); + setRssUrl(''); + } else { + const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim(); + const shelf = await addHardcover(apiToken.trim(), finalId); + setSuccessMessage(`Added list "${shelf.name}" successfully!`); + setApiToken(''); + setCustomListId(''); + } + + setSuccess(true); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } catch { + // Error is handled by the hooks + } + }; + + const handleClose = () => { + setRssUrl(''); + setApiToken(''); + setCustomListId(''); + setValidationError(''); + setSuccess(false); + setSuccessMessage(''); + onClose(); + }; + + return ( + +
+ + {/* Provider Tabs */} +
+ + +
+ + {/* Visual Header */} +
+ {provider === 'goodreads' ? ( + <> +
+ Goodreads +
+

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

+ + ) : ( + <> +
+ Hardcover +
+

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

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

{successMessage}

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

{currentError}

+
+ )} + + {/* Form */} +
+ {provider === 'goodreads' ? ( +
+ { 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. +

+
+ ) : ( + + )} + +
+ + +
+ +
+
+ ); +} 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 new file mode 100644 index 0000000..3f58745 --- /dev/null +++ b/src/components/ui/ManageShelfModal.tsx @@ -0,0 +1,153 @@ +/** + * Component: Manage Shelf Modal + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { GenericShelf } from '@/lib/hooks/useShelves'; +import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { cn } from '@/lib/utils/cn'; + +interface ManageShelfModalProps { + shelf: GenericShelf | null; + isOpen: boolean; + onClose: () => void; +} + +export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { + const [rssUrl, setRssUrl] = useState(''); + const [listId, setListId] = useState(''); + const [apiToken, setApiToken] = useState(''); + + const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf(); + + // Reset form when shelf changes (use shelf?.id for stable reference) + React.useEffect(() => { + if (shelf) { + setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : ''); + setListId(shelf.type === 'hardcover' ? shelf.sourceId : ''); + setApiToken(''); + } + }, [shelf?.id]); + + if (!shelf) return null; + + const isUpdating = isUpdatingGoodreads || isUpdatingHardcover; + const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (shelf.type === 'goodreads') { + if (!rssUrl.trim()) return; + await updateGoodreads(shelf.id, rssUrl.trim()); + } else { + if (!listId.trim()) return; + await updateHardcover(shelf.id, { + listId: listId.trim(), + apiToken: apiToken.trim() || undefined, + }); + } + onClose(); + } catch (err) { + // Error is handled by hook + } + }; + + const isGoodreads = shelf.type === 'goodreads'; + + return ( + +
+ {currentError && ( +
+
+ + + +
+

{currentError}

+
+ )} + +
+ {isGoodreads ? ( +
+ + setRssUrl(e.target.value)} + placeholder="https://www.goodreads.com/review/list_rss/..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ ) : ( + <> +
+ + setListId(e.target.value)} + placeholder="e.g., 1234, want-to-read, status-1" + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+
+ + setApiToken(e.target.value)} + placeholder="Paste your Hardcover token here..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ + )} + +
+ + +
+
+
+
+ ); +} 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 c803663..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,101 +19,29 @@ 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); +export const useDeleteGoodreadsShelf = useDelete; - const deleteShelf = async (shelfId: string) => { - if (!accessToken) throw new Error('Not authenticated'); +export function useUpdateGoodreadsShelf() { + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); - 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); - } + const updateShelf = async (shelfId: string, rssUrl: string) => { + return updateGeneric(shelfId, { rssUrl }); }; - return { deleteShelf, isLoading, error }; + return { updateShelf, isLoading, error }; } diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts new file mode 100644 index 0000000..b845917 --- /dev/null +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -0,0 +1,50 @@ +/** + * Component: Hardcover Shelves Hook + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import { createShelfHooks, ShelfBook } from './createShelfHooks'; + +export type { ShelfBook }; + +export interface HardcoverShelf { + id: string; + name: string; + listId: string; + lastSyncAt: string | null; + createdAt: string; + bookCount: number | null; + books: ShelfBook[]; +} + +const { useList, useAdd, useDelete, useUpdate } = + createShelfHooks('/api/user/hardcover-shelves'); + +export const useHardcoverShelves = useList; + +export function useAddHardcoverShelf() { + const { addShelf: addGeneric, isLoading, error } = useAdd(); + + const addShelf = async (apiToken: string, listId: string) => { + return addGeneric({ apiToken, listId }); + }; + + return { addShelf, isLoading, error }; +} + +export const useDeleteHardcoverShelf = useDelete; + +export function useUpdateHardcoverShelf() { + const { updateShelf: updateGeneric, isLoading, error } = useUpdate(); + + const updateShelf = async ( + shelfId: string, + updates: { listId?: string; apiToken?: string }, + ) => { + return updateGeneric(shelfId, updates); + }; + + return { updateShelf, isLoading, error }; +} diff --git a/src/lib/hooks/useShelves.ts b/src/lib/hooks/useShelves.ts new file mode 100644 index 0000000..f8b7f26 --- /dev/null +++ b/src/lib/hooks/useShelves.ts @@ -0,0 +1,40 @@ +/** + * Component: Shelves Hook + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import useSWR from 'swr'; +import { useAuth } from '@/contexts/AuthContext'; +import { fetchWithAuth } from '@/lib/utils/api'; +import { ShelfBook } from './useGoodreadsShelves'; + +export interface GenericShelf { + id: string; + type: 'goodreads' | 'hardcover'; + name: string; + sourceId: string; // Either rssUrl or listId + lastSyncAt: string | null; + createdAt: string; + bookCount: number | null; + books: ShelfBook[]; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +export function useShelves() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/shelves' : null; + + const { data, error, isLoading } = useSWR(endpoint, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as GenericShelf[], + isLoading, + error, + }; +} diff --git a/src/lib/processors/sync-goodreads-shelves.processor.ts b/src/lib/processors/sync-goodreads-shelves.processor.ts deleted file mode 100644 index 21f25c1..0000000 --- a/src/lib/processors/sync-goodreads-shelves.processor.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Component: Sync Goodreads Shelves Processor - * Documentation: documentation/backend/services/scheduler.md - * - * Dedicated processor for syncing Goodreads shelf RSS feeds. - * Resolves books to Audible ASINs and creates requests. - */ - -import { RMABLogger } from '../utils/logger'; - -export interface SyncGoodreadsShelvesPayload { - jobId?: string; - scheduledJobId?: string; - /** If set, only process this specific shelf (used for immediate sync on add) */ - shelfId?: string; - /** Max Audible lookups per shelf. 0 = unlimited. */ - maxLookupsPerShelf?: number; -} - -export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise { - const { jobId, shelfId, maxLookupsPerShelf } = payload; - const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves'); - - logger.info(shelfId - ? `Starting immediate Goodreads sync for shelf ${shelfId}...` - : 'Starting scheduled Goodreads shelves sync...' - ); - - const { processGoodreadsShelves } = await import('../services/goodreads-sync.service'); - const stats = await processGoodreadsShelves(logger, { - shelfId, - maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), - }); - - logger.info('Goodreads sync complete', { stats }); - - return { - success: true, - message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced', - ...stats, - }; -} diff --git a/src/lib/processors/sync-shelves.processor.ts b/src/lib/processors/sync-shelves.processor.ts new file mode 100644 index 0000000..50c76a0 --- /dev/null +++ b/src/lib/processors/sync-shelves.processor.ts @@ -0,0 +1,96 @@ +/** + * Component: Sync Shelves Processor + * Documentation: documentation/backend/services/scheduler.md + * + * Dedicated processor for syncing all reading shelves (Goodreads, Hardcover). + * Resolves books to Audible ASINs and creates requests. + */ + +import { RMABLogger } from '../utils/logger'; + +export interface SyncShelvesPayload { + jobId?: string; + scheduledJobId?: string; + /** If set, only process this specific shelf (used for immediate sync on add) */ + shelfId?: string; + /** The type of shelf, if shelfId is specified */ + shelfType?: 'goodreads' | 'hardcover'; + /** Max Audible lookups per shelf. 0 = unlimited. */ + maxLookupsPerShelf?: number; +} + +export async function processSyncShelves( + payload: SyncShelvesPayload, +): Promise { + const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload; + const logger = RMABLogger.forJob(jobId, 'SyncShelves'); + + const stats = { + shelvesProcessed: 0, + booksFound: 0, + lookupsPerformed: 0, + requestsCreated: 0, + errors: 0, + }; + + logger.info( + shelfId + ? `Starting immediate ${shelfType} sync for list ${shelfId}...` + : 'Starting scheduled shelves sync...', + ); + + const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads'; + const shouldSyncHardcover = !shelfType || shelfType === 'hardcover'; + + if (shouldSyncGoodreads) { + try { + const { processGoodreadsShelves } = + await import('../services/goodreads-sync.service'); + const grStats = await processGoodreadsShelves(logger, { + shelfId: shelfType === 'goodreads' ? shelfId : undefined, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + stats.shelvesProcessed += grStats.shelvesProcessed; + stats.booksFound += grStats.booksFound; + stats.lookupsPerformed += grStats.lookupsPerformed; + stats.requestsCreated += grStats.requestsCreated; + stats.errors += grStats.errors; + } catch (error) { + logger.error('Goodreads sync failed', { + error: error instanceof Error ? error.message : String(error), + }); + stats.errors++; + } + } + + if (shouldSyncHardcover) { + try { + const { processHardcoverShelves } = + await import('../services/hardcover-sync.service'); + const hcStats = await processHardcoverShelves(logger, { + shelfId: shelfType === 'hardcover' ? shelfId : undefined, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + stats.shelvesProcessed += hcStats.shelvesProcessed; + stats.booksFound += hcStats.booksFound; + stats.lookupsPerformed += hcStats.lookupsPerformed; + stats.requestsCreated += hcStats.requestsCreated; + stats.errors += hcStats.errors; + } catch (error) { + logger.error('Hardcover sync failed', { + error: error instanceof Error ? error.message : String(error), + }); + stats.errors++; + } + } + + logger.info('Shelves sync complete', { stats }); + + return { + success: true, + message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced', + ...stats, + }; +} 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..1c568a3 --- /dev/null +++ b/src/lib/services/hardcover-api.service.ts @@ -0,0 +1,327 @@ +/** + * 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'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('HardcoverAPI'); +const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; + +export interface HardcoverApiBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */ +interface HardcoverBookNode { + id?: number; + title?: string; + cached_image?: string | { url?: string }; + image?: { url?: string }; + contributions?: Array<{ author?: { name?: string } }>; +} + +/** Shape of a list object returned from the Hardcover GraphQL API */ +interface HardcoverListData { + name?: string; + list_books?: Array<{ book?: HardcoverBookNode }>; +} + +const PAGE_SIZE = 100; +const MAX_PAGES = 50; + +/** Extract HardcoverApiBook[] from an array of book-containing items */ +function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] { + const books: HardcoverApiBook[] = []; + for (const item of items) { + 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 books; +} + +/** + * 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!, $limit: Int!, $offset: Int!) { + me { + user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) { + book { + id + title + contributions { + author { + name + } + } + cached_image + image { + url + } + } + } + } + } + `; + + // Map status numbers to names + const statusNames: Record = { + 1: 'Want to Read', + 2: 'Currently Reading', + 3: 'Read', + 4: 'Did Not Finish', + }; + const listName = statusNames[statusId] || `Status ${statusId}`; + + const allBooks: HardcoverApiBook[] = []; + let offset = 0; + let page = 0; + + // Paginate until fewer results than PAGE_SIZE are returned + while (++page <= MAX_PAGES) { + const response = await axios.post( + HARDCOVER_API_URL, + { query, variables: { statusId, limit: PAGE_SIZE, offset } }, + { + 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: Array<{ book?: HardcoverBookNode }> = + response.data?.data?.me?.[0]?.user_books || []; + const pageBooks = extractBooks(userBooks); + allBooks.push(...pageBooks); + + if (userBooks.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + return { listName, books: allBooks }; + } 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: $limit, offset: $offset, 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!, $limit: Int!, $offset: 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!, $limit: Int!, $offset: Int!) { + 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!, $limit: Int!, $offset: Int!) { + me { + lists(where: {slug: {_eq: $slug}}, limit: 1) { + ${listBookFields} + } + } + } + `; + + let activeQuery: string; + let baseVariables: Record; + + if (isIntId) { + activeQuery = queryById; + baseVariables = { listId: parseInt(listIdStr, 10) }; + } else if (extractedUsername) { + activeQuery = queryByUserSlug; + baseVariables = { username: extractedUsername, slug: extractedSlug }; + } else { + activeQuery = queryByMySlug; + baseVariables = { slug: extractedSlug }; + } + + // First request to discover list metadata and first page of books + const firstResponse = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); + + if (firstResponse.data?.errors) { + throw new Error( + `Hardcover API Error: ${firstResponse.data.errors[0]?.message}`, + ); + } + + // Extract lists array from the response based on which query was used + let listsData: HardcoverListData[]; + if (isIntId) { + listsData = firstResponse.data?.data?.lists || []; + } else if (extractedUsername) { + const users = firstResponse.data?.data?.users || []; + listsData = users[0]?.lists || []; + } else { + listsData = firstResponse.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 firstPageItems = listsData[0].list_books || []; + const allBooks = extractBooks(firstPageItems); + + // Paginate if first page was full + if (firstPageItems.length >= PAGE_SIZE) { + let offset = PAGE_SIZE; + let page = 1; // first page already fetched + + while (++page <= MAX_PAGES) { + const pageResponse = await axios.post( + HARDCOVER_API_URL, + { + query: activeQuery, + variables: { ...baseVariables, limit: PAGE_SIZE, offset }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }, + ); + + if (pageResponse.data?.errors) { + logger.warn('Hardcover pagination interrupted by API error', { + errors: pageResponse.data.errors, + offset, + }); + break; + } + + let pageListsData: HardcoverListData[]; + if (isIntId) { + pageListsData = pageResponse.data?.data?.lists || []; + } else if (extractedUsername) { + const users = pageResponse.data?.data?.users || []; + pageListsData = users[0]?.lists || []; + } else { + pageListsData = pageResponse.data?.data?.me?.[0]?.lists || []; + } + + const pageItems = pageListsData[0]?.list_books || []; + allBooks.push(...extractBooks(pageItems)); + + if (pageItems.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + } + + return { listName, books: allBooks }; + } +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts new file mode 100644 index 0000000..11bb4f3 --- /dev/null +++ b/src/lib/services/hardcover-sync.service.ts @@ -0,0 +1,122 @@ +/** + * Component: Hardcover Shelf Sync Service + * Documentation: documentation/backend/services/hardcover-sync.md + * + * Fetches Hardcover lists via GraphQL API and delegates book processing + * to the shared shelf-sync-core service. + */ + +import { prisma } from '@/lib/db'; +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'); + +// Re-export types that downstream consumers expect +export type { ShelfSyncStats as HardcoverSyncStats }; +export type { ShelfSyncOptions as HardcoverSyncOptions }; + +/** + * Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests. + * Called from the unified sync_reading_shelves processor. + */ +export async function processHardcoverShelves( + jobLogger?: ReturnType, + options: ShelfSyncOptions = {}, +): Promise { + const log = jobLogger || logger; + const stats = createEmptyStats(); + const maxLookups = resolveMaxLookups(options); + + const whereClause = options.shelfId ? { id: options.shelfId } : {}; + const shelves = await prisma.hardcoverShelf.findMany({ + where: whereClause, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + if (shelves.length === 0) { + log.info( + options.shelfId + ? 'Hardcover list not found' + : 'No Hardcover lists configured, skipping', + ); + return stats; + } + + log.info( + `Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`, + ); + + for (const shelf of shelves) { + try { + 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}`); + stats.errors++; + continue; + } + + 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++; + log.error( + `Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + log.info( + `Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`, + ); + return stats; +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 900b221..8e9bcc4 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -26,7 +26,7 @@ export type JobType = | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' - | 'sync_goodreads_shelves' + | 'sync_reading_shelves' | 'check_watched_lists' | 'send_notification' // Ebook-specific job types @@ -108,9 +108,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload { scheduledJobId?: string; } -export interface SyncGoodreadsShelvesPayload extends JobPayload { +export interface SyncShelvesPayload extends JobPayload { scheduledJobId?: string; shelfId?: string; + shelfType?: 'goodreads' | 'hardcover'; maxLookupsPerShelf?: number; } @@ -389,10 +390,10 @@ export class JobQueueService { return await processCleanupSeededTorrents(payloadWithJobId); }); - this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob) => { - const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves'); - return await processSyncGoodreadsShelves(payloadWithJobId); + this.queue.process('sync_reading_shelves', 1, async (job: BullJob) => { + const { processSyncShelves } = await import('../processors/sync-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves'); + return await processSyncShelves(payloadWithJobId); }); this.queue.process('check_watched_lists', 1, async (job: BullJob) => { @@ -767,16 +768,17 @@ export class JobQueueService { } /** - * Add sync Goodreads shelves job + * Add sync reading shelves job */ - async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise { + async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise { return await this.addJob( - 'sync_goodreads_shelves', + 'sync_reading_shelves', { scheduledJobId, shelfId, + shelfType, maxLookupsPerShelf, - } as SyncGoodreadsShelvesPayload, + } as SyncShelvesPayload, { priority: 7, } diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index 28d7690..785af60 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Scheduler'); -export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists'; +export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists'; export interface ScheduledJob { id: string; @@ -59,6 +59,9 @@ export class SchedulerService { }); } + // Clean up deprecated scheduled jobs + await this.cleanupDeprecatedJobs(); + // Create default jobs if they don't exist await this.ensureDefaultJobs(); @@ -127,8 +130,8 @@ export class SchedulerService { payload: {}, }, { - name: 'Sync Goodreads Shelves', - type: 'sync_goodreads_shelves' as ScheduledJobType, + name: 'Sync Reading Shelves', + type: 'sync_reading_shelves' as ScheduledJobType, schedule: '0 */6 * * *', // Every 6 hours enabled: true, // Enable by default payload: {}, @@ -174,6 +177,31 @@ export class SchedulerService { } } + /** + * Remove any old jobs that are no longer supported + */ + private async cleanupDeprecatedJobs(): Promise { + try { + const deprecatedTypes = ['sync_goodreads_shelves']; + + const obsoleteJobs = await prisma.scheduledJob.findMany({ + where: { type: { in: deprecatedTypes } }, + }); + + for (const job of obsoleteJobs) { + if (job.enabled) { + await this.unscheduleJob(job); + } + await prisma.scheduledJob.delete({ where: { id: job.id } }); + logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`); + } + } catch (error) { + logger.error('Failed to cleanup deprecated scheduled jobs', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** * Schedule all enabled jobs */ @@ -357,8 +385,8 @@ export class SchedulerService { case 'monitor_rss_feeds': bullJobId = await this.triggerMonitorRssFeeds(job); break; - case 'sync_goodreads_shelves': - bullJobId = await this.triggerSyncGoodreadsShelves(job); + case 'sync_reading_shelves': + bullJobId = await this.triggerSyncShelves(job); break; case 'check_watched_lists': bullJobId = await this.triggerCheckWatchedLists(job); @@ -632,10 +660,10 @@ export class SchedulerService { } /** - * Trigger Goodreads shelves sync + * Trigger Reading shelves sync */ - private async triggerSyncGoodreadsShelves(job: any): Promise { - return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id); + private async triggerSyncShelves(job: any): Promise { + return await this.jobQueue.addSyncShelvesJob(job.id); } /** 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/api/goodreads-shelves-id.routes.test.ts b/tests/api/goodreads-shelves-id.routes.test.ts new file mode 100644 index 0000000..97bff25 --- /dev/null +++ b/tests/api/goodreads-shelves-id.routes.test.ts @@ -0,0 +1,186 @@ +/** + * Component: Goodreads Shelves [id] API Route Tests + * Documentation: documentation/backend/services/goodreads-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +const SHELF = { + id: 'shelf-1', + userId: 'user-1', + name: 'Want to Read', + rssUrl: 'https://www.goodreads.com/review/list_rss/12345', + lastSyncAt: null, + bookCount: 5, + coverUrls: null, + createdAt: new Date().toISOString(), +}; + +describe('DELETE /api/user/goodreads-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('deletes the shelf and returns success', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.delete).toHaveBeenCalledWith({ where: { id: 'shelf-1' } }); + }); + + it('returns 500 when deletion throws', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockRejectedValueOnce(new Error('db error')); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('Failed to delete shelf'); + }); +}); + +describe('PATCH /api/user/goodreads-shelves/[id]', () => { + const NEW_RSS = 'https://www.goodreads.com/review/list_rss/99999'; + + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns 400 for an invalid (non-URL) rssUrl', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: 'not-a-url' }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('updates the shelf, clears sync metadata, and triggers a sync job', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + const updatedShelf = { ...SHELF, rssUrl: NEW_RSS, lastSyncAt: null }; + prismaMock.goodreadsShelf.update.mockResolvedValueOnce(updatedShelf); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.update).toHaveBeenCalledWith({ + where: { id: 'shelf-1' }, + data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0); + }); + + it('still returns 200 even when the sync job fails to enqueue', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.update.mockResolvedValueOnce({ ...SHELF, rssUrl: NEW_RSS }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + // Sync job failure is swallowed; shelf update should still succeed + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); +}); diff --git a/tests/api/hardcover-shelves-id.routes.test.ts b/tests/api/hardcover-shelves-id.routes.test.ts new file mode 100644 index 0000000..7d084ea --- /dev/null +++ b/tests/api/hardcover-shelves-id.routes.test.ts @@ -0,0 +1,233 @@ +/** + * Component: Hardcover Shelves [id] API Route Tests + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((s: string) => `enc:${s}`), + decrypt: vi.fn((s: string) => s.replace('enc:', '')), + isEncryptedFormat: vi.fn((s: string) => s.startsWith('enc:')), +})); + +const fetchHardcoverListMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/services/hardcover-api.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + +const SHELF = { + id: 'hc-shelf-1', + userId: 'user-1', + name: 'Currently Reading', + listId: 'status-2', + apiToken: 'enc:secret-token', + lastSyncAt: null, + bookCount: 3, + coverUrls: null, + createdAt: new Date().toISOString(), +}; + +describe('DELETE /api/user/hardcover-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when list does not exist', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('List not found'); + }); + + it('returns 403 when list belongs to another user', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('deletes the list and returns success', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.hardcoverShelf.delete).toHaveBeenCalledWith({ where: { id: 'hc-shelf-1' } }); + }); + + it('returns 500 when deletion throws', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.delete.mockRejectedValueOnce(new Error('db error')); + + const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('Failed to delete list'); + }); +}); + +describe('PATCH /api/user/hardcover-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + encryptionMock.isEncryptedFormat.mockImplementation((s: string) => s.startsWith('enc:')); + encryptionMock.encrypt.mockImplementation((s: string) => `enc:${s}`); + encryptionMock.decrypt.mockImplementation((s: string) => s.replace('enc:', '')); + fetchHardcoverListMock.mockResolvedValue({ listName: 'Test List', books: [] }); + }); + + it('returns 404 when list does not exist', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('List not found'); + }); + + it('returns 403 when list belongs to another user', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('does not trigger a sync when no fields changed', async () => { + // listId is the same as existing; no apiToken provided + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: SHELF.listId }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled(); + }); + + it('updates listId, clears metadata, and triggers a sync job', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null }; + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(updated); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({ + where: { id: 'hc-shelf-1' }, + data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }), + }); + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0); + }); + + it('encrypts the apiToken before persisting', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + await PATCH( + { json: vi.fn().mockResolvedValue({ apiToken: 'my-raw-token' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + + expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token'); + expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({ + where: { id: 'hc-shelf-1' }, + data: expect.objectContaining({ apiToken: 'enc:my-raw-token' }), + }); + }); + + it('strips the Bearer prefix before encrypting the token', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + await PATCH( + { json: vi.fn().mockResolvedValue({ apiToken: 'Bearer my-raw-token' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + + expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token'); + }); + + it('still returns 200 even when the sync job fails to enqueue', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.hardcoverShelf.update.mockResolvedValueOnce({ ...SHELF, listId: 'status-3' }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any, + { params: Promise.resolve({ id: 'hc-shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); +}); diff --git a/tests/api/hardcover-shelves.routes.test.ts b/tests/api/hardcover-shelves.routes.test.ts new file mode 100644 index 0000000..176bf1e --- /dev/null +++ b/tests/api/hardcover-shelves.routes.test.ts @@ -0,0 +1,216 @@ +/** + * Component: Hardcover Shelves API Route Tests (POST / GET) + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSyncShelvesJob: vi.fn(() => Promise.resolve()), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((s: string) => `enc:${s}`), + decrypt: vi.fn((s: string) => s.replace('enc:', '')), +})); +const fetchHardcoverListMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/services/hardcover-api.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + +const FETCHED_LIST = { + listName: 'Currently Reading', + books: [ + { title: 'Dune', author: 'Frank Herbert', coverUrl: 'https://example.com/dune.jpg' }, + { title: 'Foundation', author: 'Isaac Asimov', coverUrl: null }, + ], +}; + +describe('POST /api/user/hardcover-shelves', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ listId: 'status-2', apiToken: 'raw-token' }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 400 when apiToken is missing', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 400 when listId is missing', async () => { + authRequest.json.mockResolvedValueOnce({ apiToken: 'raw-token' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 409 when the list is already subscribed', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ id: 'existing-shelf' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('DuplicateShelf'); + }); + + it('returns 400 when Hardcover API fetch fails', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockRejectedValueOnce(new Error('Invalid token')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('InvalidHardcoverList'); + expect(payload.message).toContain('Invalid token'); + }); + + it('creates the shelf with an encrypted token and triggers sync', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(payload.shelf.name).toBe('Currently Reading'); + + // Token must have been encrypted before storage + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + apiToken: 'enc:raw-token', + listId: 'status-2', + userId: 'user-1', + }), + }) + ); + + // Immediate background sync must have been triggered + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0); + }); + + it('strips Bearer prefix from apiToken before encrypting', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2', apiToken: 'Bearer raw-token' }); + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-2', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + await POST({} as any); + + // "Bearer " prefix must have been stripped before encrypt was called + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + }); + + it('returns 201 even when the sync job fails to enqueue', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-3', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + }); + + it('only includes books with cover URLs in the initial shelf preview', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); // only 1 of 2 books has coverUrl + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-4', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + // The coverUrls stored should only include books with non-null coverUrl + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + // 1 book has cover, 1 doesn't → only 1 stored + coverUrls: JSON.stringify([ + { coverUrl: 'https://example.com/dune.jpg', asin: null, title: 'Dune', author: 'Frank Herbert' }, + ]), + }), + }) + ); + }); +}); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 8b6debb..ae7b943 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -46,7 +46,8 @@ export const createPrismaMock = () => ({ bookDateRecommendation: createModelMock(), bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), - goodreadsBookMapping: createModelMock(), + bookMapping: createModelMock(), + hardcoverShelf: createModelMock(), apiToken: createModelMock(), work: createModelMock(), workAsin: createModelMock(), diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index fd23199..247e8c0 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -21,7 +21,7 @@ const processorsMock = vi.hoisted(() => ({ processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), - processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'), + processSyncShelves: vi.fn().mockResolvedValue('ok'), processCheckWatchedLists: vi.fn().mockResolvedValue('ok'), // Ebook processors processSearchEbook: vi.fn().mockResolvedValue('ok'), @@ -117,8 +117,8 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, })); -vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({ - processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, +vi.mock('@/lib/processors/sync-shelves.processor', () => ({ + processSyncShelves: processorsMock.processSyncShelves, })); vi.mock('@/lib/processors/check-watched-lists.processor', () => ({ @@ -569,7 +569,7 @@ describe('JobQueueService', () => { expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); - expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled(); + expect(processorsMock.processSyncShelves).toHaveBeenCalled(); expect(processorsMock.processCheckWatchedLists).toHaveBeenCalled(); }); diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index b86349f..b294e81 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,7 +18,7 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), - addSyncGoodreadsShelvesJob: vi.fn(), + addSyncShelvesJob: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ @@ -63,7 +63,9 @@ describe('SchedulerService', () => { prismaMock.scheduledJob.findFirst.mockResolvedValue(null); prismaMock.scheduledJob.create.mockResolvedValue({}); prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([]) // cleanupDeprecatedJobs .mockResolvedValueOnce([ + // scheduleAllJobs { id: 'job-1', name: 'Audible Data Refresh', @@ -72,7 +74,7 @@ describe('SchedulerService', () => { enabled: true, }, ]) - .mockResolvedValueOnce([]); + .mockResolvedValue([]); // triggerOverdueJobs const { SchedulerService } = await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); @@ -289,7 +291,7 @@ describe('SchedulerService', () => { ['retry_failed_imports', 'addRetryFailedImportsJob'], ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], ['monitor_rss_feeds', 'addMonitorRssFeedsJob'], - ['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'], + ['sync_reading_shelves', 'addSyncShelvesJob'], ])('triggers %s jobs with job queue', async (type, queueMethod) => { prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-type',