# BookDate Feature - AI Agent Implementation Prompt ## Task Overview Implement the **BookDate** feature for ReadMeABook - an AI-powered audiobook recommendation system with a Tinder-style swipe interface. This is a complete 0-to-MVP implementation covering backend, frontend, database, and integration work. **PRD Location:** `/home/user/ReadMeABook/documentation/features/bookdate-prd.md` **Goal:** Deliver a working MVP where users can: 1. Configure AI provider (OpenAI/Claude) in setup wizard or settings 2. View personalized audiobook recommendations in a swipeable interface 3. Swipe right to request, left to reject, up to dismiss 4. Have recommendations improve based on their Plex library and swipe history --- ## Project Context ### Tech Stack - **Frontend:** Next.js 14+, TypeScript, Tailwind CSS - **Backend:** Next.js API routes (Node.js/Express patterns) - **Database:** PostgreSQL with Prisma ORM - **Deployment:** Single Docker container - **File Structure:** `src/app/` for pages, `src/components/` for UI, `src/lib/` for utilities ### Existing Patterns to Follow **Database:** - Schema defined in `prisma/schema.prisma` - Use `prisma db push` for schema sync (no migrations) - Prisma client output: `src/generated/prisma` - Encrypted fields: Use AES-256 for API keys (see `backend/services/config.md`) **API Routes:** - Location: `src/app/api/[feature]/route.ts` - Auth middleware: `requireAuth()`, `requireAdmin()` (see `backend/services/auth.md`) - Response format: `NextResponse.json({...})` **Frontend:** - Route groups: `(user)` for user pages, `(admin)` for admin - Protected routes: Wrap with auth check (see `frontend/routing-auth.md`) - Components: Reusable in `src/components/`, page-specific in `src/app/[page]/` - Styling: Tailwind CSS, dark mode support **Setup Wizard:** - Location: `src/app/setup/` - 8-step pattern with progress indicator - Current steps: Welcome, Admin, Plex, Prowlarr, Download Client, Paths, Review, Finalize - BookDate should be inserted as **step 7** (after Paths validation, before Review) - Steps are components: `WelcomeStep.tsx`, `AdminStep.tsx`, etc. - State management: Local state passed between steps - See `documentation/setup-wizard.md` for structure ### Key Documentation to Reference **MANDATORY - Read First:** - `documentation/TABLEOFCONTENTS.md` - Navigation guide (read THIS first) - `documentation/features/bookdate-prd.md` - Complete feature requirements **For Implementation:** - `documentation/backend/database.md` - Schema patterns, encryption - `documentation/backend/services/auth.md` - Auth middleware usage - `documentation/setup-wizard.md` - Wizard integration patterns - `documentation/settings-pages.md` - Settings UI patterns - `documentation/frontend/components.md` - UI component catalog - `documentation/integrations/plex.md` - Plex API integration patterns --- ## Implementation Phases ### Phase 1: Database Schema (Priority: Critical) **Create new Prisma models in `prisma/schema.prisma`:** ```prisma model BookDateConfig { id String @id @default(uuid()) userId String @unique provider String // 'openai' | 'claude' apiKey String // Encrypted at rest model String // e.g., 'gpt-4o', 'claude-sonnet-4-5' libraryScope String // 'full' | 'listened' | 'rated' customPrompt String? isVerified Boolean @default(false) isEnabled Boolean @default(true) // Admin toggle (global) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } model BookDateRecommendation { id String @id @default(uuid()) userId String batchId String // Group recommendations from same AI call title String author String narrator String? rating Float? description String? coverUrl String? audnexusAsin String? // For matching aiReason String // Why AI recommended this createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) swipes BookDateSwipe[] @@index([userId, batchId]) @@index([userId, createdAt]) } model BookDateSwipe { id String @id @default(uuid()) userId String recommendationId String? // NULL if book not from BookDate bookTitle String bookAuthor String action String // 'left' | 'right' | 'up' markedAsKnown Boolean @default(false) // True if "Mark as Known" createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) recommendation BookDateRecommendation? @relation(fields: [recommendationId], references: [id], onDelete: SetNull) @@index([userId, createdAt]) @@index([recommendationId]) } ``` **User model update:** Add relationships: ```prisma model User { // ... existing fields ... bookDateConfig BookDateConfig? bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] } ``` **After schema changes:** 1. Run `npx prisma db push` to sync schema 2. Run `npx prisma generate` to regenerate client 3. Verify in database that tables created correctly **Encryption:** - API keys in `BookDateConfig.apiKey` MUST be encrypted - Use existing encryption utility (see `backend/services/config.md`) - Pattern: `encrypt(apiKey)` before save, `decrypt(apiKey)` on read --- ### Phase 2: Backend API - Configuration (Priority: Critical) **Create API routes in `src/app/api/bookdate/`:** #### 2.1 Test Connection & Fetch Models **File:** `src/app/api/bookdate/test-connection/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; // Adjust path as needed export async function POST(req: NextRequest) { // Auth check const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { provider, apiKey } = await req.json(); // Validate inputs if (!provider || !apiKey) { return NextResponse.json({ error: 'Provider and API key required' }, { status: 400 }); } try { let models = []; if (provider === 'openai') { // OpenAI: Fetch models from https://api.openai.com/v1/models const response = await fetch('https://api.openai.com/v1/models', { headers: { 'Authorization': `Bearer ${apiKey}` } }); if (!response.ok) { return NextResponse.json({ error: 'Invalid OpenAI API key' }, { status: 400 }); } const data = await response.json(); // Filter to relevant models (gpt-4o, gpt-4-turbo, etc.) models = data.data .filter((m: any) => m.id.startsWith('gpt-')) .map((m: any) => ({ id: m.id, name: m.id })); } else if (provider === 'claude') { // Claude: Hardcoded list (Anthropic doesn't have a models API endpoint) models = [ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, { id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, ]; // Test connection with a simple API call const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model: 'claude-3-5-haiku-20241022', max_tokens: 10, messages: [{ role: 'user', content: 'Hi' }] }) }); if (!response.ok) { return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 }); } } else { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); } return NextResponse.json({ success: true, models }); } catch (error: any) { console.error('[BookDate] Test connection error:', error); return NextResponse.json({ error: error.message || 'Connection failed' }, { status: 500 }); } } ``` #### 2.2 Save/Update Configuration **File:** `src/app/api/bookdate/config/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; // Adjust path import { encrypt, decrypt } from '@/lib/encryption'; // Adjust path // GET: Fetch user's config (excluding API key) export async function GET(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const config = await prisma.bookDateConfig.findUnique({ where: { userId: user.id } }); if (!config) { return NextResponse.json({ config: null }); } // Don't return API key const { apiKey, ...safeConfig } = config; return NextResponse.json({ config: safeConfig }); } // POST: Create/update config export async function POST(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { provider, apiKey, model, libraryScope, customPrompt } = await req.json(); // Validation if (!provider || !apiKey || !model || !libraryScope) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } if (!['openai', 'claude'].includes(provider)) { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); } if (!['full', 'listened', 'rated'].includes(libraryScope)) { return NextResponse.json({ error: 'Invalid library scope' }, { status: 400 }); } try { // Encrypt API key const encryptedApiKey = encrypt(apiKey); // Upsert config const config = await prisma.bookDateConfig.upsert({ where: { userId: user.id }, update: { provider, apiKey: encryptedApiKey, model, libraryScope, customPrompt: customPrompt || null, isVerified: true, updatedAt: new Date() }, create: { userId: user.id, provider, apiKey: encryptedApiKey, model, libraryScope, customPrompt: customPrompt || null, isVerified: true } }); // Clear cached recommendations when config changes await prisma.bookDateRecommendation.deleteMany({ where: { userId: user.id } }); return NextResponse.json({ success: true, config: { ...config, apiKey: undefined } }); } catch (error: any) { console.error('[BookDate] Save config error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // DELETE: Remove config export async function DELETE(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } await prisma.bookDateConfig.delete({ where: { userId: user.id } }); return NextResponse.json({ success: true }); } ``` #### 2.3 Admin Toggle (Global Enable/Disable) **File:** `src/app/api/admin/bookdate/toggle/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAdmin } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; export async function PATCH(req: NextRequest) { const admin = await requireAdmin(req); if (!admin) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const { isEnabled } = await req.json(); // Update all configs await prisma.bookDateConfig.updateMany({ data: { isEnabled } }); return NextResponse.json({ success: true, isEnabled }); } ``` --- ### Phase 3: Backend API - Recommendations (Priority: Critical) #### 3.1 Helper Functions **File:** `src/lib/bookdate/helpers.ts` Create utility functions: ```typescript import { prisma } from '@/lib/prisma'; import { decrypt } from '@/lib/encryption'; // Get user's Plex library books based on scope export async function getUserLibraryBooks(userId: string, scope: 'full' | 'listened' | 'rated') { // Query Plex API or database cache // For 'full': Return all audiobooks in user's Plex library // For 'listened': Filter by viewOffset/duration > 25% // For 'rated': Filter by user ratings > 0 // Implementation note: Use existing Plex integration patterns // See documentation/integrations/plex.md // Return format: return [ { title: 'Example Book', author: 'Author Name', narrator: 'Narrator Name', genres: ['Fiction', 'Sci-Fi'], rating: 4.5, listenStatus: 'completed' // or 'partial', 'unplayed' } // ... up to 40 latest books ]; } // Get user's recent swipes export async function getUserRecentSwipes(userId: string, limit: number = 10) { const swipes = await prisma.bookDateSwipe.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: limit, select: { bookTitle: true, bookAuthor: true, action: true, createdAt: true } }); return swipes.map(s => ({ title: s.bookTitle, author: s.bookAuthor, action: s.action })); } // Build AI prompt export async function buildAIPrompt(userId: string, config: any) { const { libraryScope, customPrompt } = config; // Get context (max 50 books) const libraryBooks = await getUserLibraryBooks(userId, libraryScope); const swipeHistory = await getUserRecentSwipes(userId, 10); // Determine split (40 library + 10 swipes, adjust if needed) const maxLibraryBooks = Math.min(libraryBooks.length, 40); const contextBooks = libraryBooks.slice(0, maxLibraryBooks); const prompt = { task: 'recommend_audiobooks', user_context: { library_books: contextBooks, swipe_history: swipeHistory, custom_preferences: customPrompt || null }, instructions: 'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. Exclude books already in their library. Focus on variety and quality. Return ONLY valid JSON.', response_format: { recommendations: [ { title: 'string', author: 'string', reason: '1-2 sentence explanation' } ] } }; return JSON.stringify(prompt); } // Call AI API export async function callAI(provider: string, model: string, apiKey: string, prompt: string) { const decryptedKey = decrypt(apiKey); if (provider === 'openai') { const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${decryptedKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'You are an expert audiobook recommender. Return ONLY valid JSON.' }, { role: 'user', content: prompt } ] }) }); if (!response.ok) { throw new Error(`OpenAI API error: ${response.statusText}`); } const data = await response.json(); return JSON.parse(data.choices[0].message.content); } else if (provider === 'claude') { const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': decryptedKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model, max_tokens: 4096, messages: [ { role: 'user', content: `${prompt}\n\nReturn ONLY valid JSON with no additional text or formatting.` } ] }) }); if (!response.ok) { throw new Error(`Claude API error: ${response.statusText}`); } const data = await response.json(); const content = data.content[0].text; return JSON.parse(content); } throw new Error('Invalid provider'); } // Match AI recommendation to Audnexus export async function matchToAudnexus(title: string, author: string) { // Search Audnexus API for title + author // Use existing Audnexus integration patterns // Return metadata or null if no match // Implementation note: Similar to existing Audible search // See integrations/audible.md or existing Audible API code return { asin: 'B0XXXXXX', title: 'Matched Title', author: 'Matched Author', narrator: 'Narrator Name', rating: 4.5, description: 'Book description...', coverUrl: 'https://...' }; } ``` #### 3.2 Get Recommendations **File:** `src/app/api/bookdate/recommendations/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { buildAIPrompt, callAI, matchToAudnexus } from '@/lib/bookdate/helpers'; export async function GET(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } try { // Check for cached recommendations const cached = await prisma.bookDateRecommendation.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'asc' }, take: 10 }); if (cached.length >= 10) { return NextResponse.json({ recommendations: cached, source: 'cache' }); } // Need to generate new recommendations const config = await prisma.bookDateConfig.findUnique({ where: { userId: user.id } }); if (!config || !config.isVerified || !config.isEnabled) { return NextResponse.json({ error: 'BookDate not configured' }, { status: 400 }); } // Build prompt and call AI const prompt = await buildAIPrompt(user.id, config); const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt); if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) { throw new Error('Invalid AI response format'); } // Match to Audnexus and filter const batchId = `batch_${Date.now()}`; const matched = []; for (const rec of aiResponse.recommendations) { // Check if already in library (skip) // Check if already requested (skip) // Check if already swiped (skip) const alreadySwiped = await prisma.bookDateSwipe.findFirst({ where: { userId: user.id, bookTitle: rec.title, bookAuthor: rec.author } }); if (alreadySwiped) continue; // Match to Audnexus try { const audnexusMatch = await matchToAudnexus(rec.title, rec.author); if (!audnexusMatch) { console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`); continue; } matched.push({ userId: user.id, batchId, title: audnexusMatch.title, author: audnexusMatch.author, narrator: audnexusMatch.narrator, rating: audnexusMatch.rating, description: audnexusMatch.description, coverUrl: audnexusMatch.coverUrl, audnexusAsin: audnexusMatch.asin, aiReason: rec.reason }); if (matched.length >= 10) break; } catch (error) { console.warn(`[BookDate] Match error for "${rec.title}":`, error); continue; } } // Save to database if (matched.length > 0) { await prisma.bookDateRecommendation.createMany({ data: matched }); } // Combine with existing cache const allRecommendations = await prisma.bookDateRecommendation.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'asc' }, take: 10 }); return NextResponse.json({ recommendations: allRecommendations, source: 'generated', generatedCount: matched.length }); } catch (error: any) { console.error('[BookDate] Recommendations error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ``` #### 3.3 Record Swipe **File:** `src/app/api/bookdate/swipe/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; export async function POST(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { recommendationId, action, markedAsKnown } = await req.json(); if (!recommendationId || !action) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } if (!['left', 'right', 'up'].includes(action)) { return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); } try { // Get recommendation const recommendation = await prisma.bookDateRecommendation.findUnique({ where: { id: recommendationId } }); if (!recommendation || recommendation.userId !== user.id) { return NextResponse.json({ error: 'Recommendation not found' }, { status: 404 }); } // Record swipe await prisma.bookDateSwipe.create({ data: { userId: user.id, recommendationId, bookTitle: recommendation.title, bookAuthor: recommendation.author, action, markedAsKnown: markedAsKnown || false } }); // Remove from cache await prisma.bookDateRecommendation.delete({ where: { id: recommendationId } }); return NextResponse.json({ success: true }); } catch (error: any) { console.error('[BookDate] Swipe error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ``` #### 3.4 Undo Swipe **File:** `src/app/api/bookdate/undo/route.ts` ```typescript import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; export async function POST(req: NextRequest) { const user = await requireAuth(req); if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } try { // Get last swipe (left or up only) const lastSwipe = await prisma.bookDateSwipe.findFirst({ where: { userId: user.id, action: { in: ['left', 'up'] } }, orderBy: { createdAt: 'desc' }, include: { recommendation: true } }); if (!lastSwipe) { return NextResponse.json({ error: 'No swipe to undo' }, { status: 404 }); } // Restore recommendation to cache (if available) if (lastSwipe.recommendation) { await prisma.bookDateRecommendation.create({ data: { userId: user.id, batchId: lastSwipe.recommendation.batchId, title: lastSwipe.recommendation.title, author: lastSwipe.recommendation.author, narrator: lastSwipe.recommendation.narrator, rating: lastSwipe.recommendation.rating, description: lastSwipe.recommendation.description, coverUrl: lastSwipe.recommendation.coverUrl, audnexusAsin: lastSwipe.recommendation.audnexusAsin, aiReason: lastSwipe.recommendation.aiReason } }); } // Delete swipe await prisma.bookDateSwipe.delete({ where: { id: lastSwipe.id } }); return NextResponse.json({ success: true, recommendation: lastSwipe.recommendation }); } catch (error: any) { console.error('[BookDate] Undo error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ``` #### 3.5 Generate More **File:** `src/app/api/bookdate/generate/route.ts` Similar to recommendations endpoint, but forces new generation (doesn't check cache first). --- ### Phase 4: Setup Wizard Integration (Priority: High) **Goal:** Add BookDate configuration as step 7 in the setup wizard (after Paths, before Review). **Files to modify:** - `src/app/setup/page.tsx` - Main wizard component - Create `src/app/setup/BookDateStep.tsx` - New step component **BookDateStep.tsx structure:** ```typescript 'use client'; import { useState } from 'react'; interface BookDateStepProps { onNext: (data: any) => void; onSkip: () => void; } export default function BookDateStep({ onNext, onSkip }: BookDateStepProps) { const [provider, setProvider] = useState<'openai' | 'claude'>('openai'); const [apiKey, setApiKey] = useState(''); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [libraryScope, setLibraryScope] = useState<'full' | 'listened' | 'rated'>('full'); const [customPrompt, setCustomPrompt] = useState(''); const [testing, setTesting] = useState(false); const [tested, setTested] = useState(false); const handleTestConnection = async () => { setTesting(true); try { const response = await fetch('/api/bookdate/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, apiKey }) }); const data = await response.json(); if (data.success) { setModels(data.models); setTested(true); } else { alert(data.error); } } catch (error) { alert('Connection test failed'); } finally { setTesting(false); } }; const handleNext = () => { onNext({ provider, apiKey, model: selectedModel, libraryScope, customPrompt }); }; return (

BookDate Setup (Optional)

Configure AI-powered audiobook recommendations. You can skip this and set it up later.

{/* Provider selection */}
{/* API key input */}
setApiKey(e.target.value)} placeholder="sk-..." className="w-full px-4 py-2 border rounded-lg" />

Your API key is stored securely and only used for recommendations

{/* Test connection button */} {/* Model selection (only shown after successful test) */} {tested && models.length > 0 && (
)} {/* Library scope */} {tested && selectedModel && (
)} {/* Custom prompt */} {tested && selectedModel && (