Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
49 KiB
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:
- Configure AI provider (OpenAI/Claude) in setup wizard or settings
- View personalized audiobook recommendations in a swipeable interface
- Swipe right to request, left to reject, up to dismiss
- 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 pushfor 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()(seebackend/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 insrc/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.mdfor 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, encryptiondocumentation/backend/services/auth.md- Auth middleware usagedocumentation/setup-wizard.md- Wizard integration patternsdocumentation/settings-pages.md- Settings UI patternsdocumentation/frontend/components.md- UI component catalogdocumentation/integrations/plex.md- Plex API integration patterns
Implementation Phases
Phase 1: Database Schema (Priority: Critical)
Create new Prisma models in prisma/schema.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:
model User {
// ... existing fields ...
bookDateConfig BookDateConfig?
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
}
After schema changes:
- Run
npx prisma db pushto sync schema - Run
npx prisma generateto regenerate client - Verify in database that tables created correctly
Encryption:
- API keys in
BookDateConfig.apiKeyMUST 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
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: Fetch models dynamically from the Anthropic Models API
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
}
const data = await response.json();
models = data.data.map((m: any) => ({
id: m.id,
name: m.display_name || m.id,
}));
} 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
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
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:
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
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
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
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:
'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<any[]>([]);
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 (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">BookDate Setup (Optional)</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Configure AI-powered audiobook recommendations. You can skip this and set it up later.
</p>
</div>
{/* Provider selection */}
<div>
<label className="block text-sm font-medium mb-2">AI Provider</label>
<select
value={provider}
onChange={(e) => setProvider(e.target.value as any)}
className="w-full px-4 py-2 border rounded-lg"
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
</select>
</div>
{/* API key input */}
<div>
<label className="block text-sm font-medium mb-2">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-4 py-2 border rounded-lg"
/>
<p className="text-xs text-gray-500 mt-1">
Your API key is stored securely and only used for recommendations
</p>
</div>
{/* Test connection button */}
<button
onClick={handleTestConnection}
disabled={!apiKey || testing}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{testing ? 'Testing...' : 'Test Connection & Fetch Models'}
</button>
{/* Model selection (only shown after successful test) */}
{tested && models.length > 0 && (
<div>
<label className="block text-sm font-medium mb-2">Select Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
>
<option value="">-- Choose a model --</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
)}
{/* Library scope */}
{tested && selectedModel && (
<div>
<label className="block text-sm font-medium mb-2">Base Recommendations On</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
value="full"
checked={libraryScope === 'full'}
onChange={(e) => setLibraryScope(e.target.value as any)}
className="mr-2"
/>
Full Plex Library
</label>
<label className="flex items-center">
<input
type="radio"
value="listened"
checked={libraryScope === 'listened'}
onChange={(e) => setLibraryScope(e.target.value as any)}
className="mr-2"
/>
Listened Books Only (>25% completed)
</label>
<label className="flex items-center">
<input
type="radio"
value="rated"
checked={libraryScope === 'rated'}
onChange={(e) => setLibraryScope(e.target.value as any)}
className="mr-2"
/>
Rated Books Only
</label>
</div>
</div>
)}
{/* Custom prompt */}
{tested && selectedModel && (
<div>
<label className="block text-sm font-medium mb-2">
Additional Preferences (Optional)
</label>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="e.g., 'I prefer sci-fi with strong female leads'"
rows={3}
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
)}
{/* Navigation buttons */}
<div className="flex gap-4">
<button
onClick={onSkip}
className="px-4 py-2 border rounded-lg"
>
Skip for now
</button>
<button
onClick={handleNext}
disabled={!tested || !selectedModel || !libraryScope}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Next
</button>
</div>
</div>
);
}
Integration points:
- Import
BookDateStepin main wizard - Add to step array between Paths (step 6) and Review (step 7)
- Handle skip logic (don't save config if skipped)
- In Review step, show BookDate config if configured
- In Finalize step, save BookDate config if provided
Phase 5: Settings Page Integration (Priority: High)
File: src/app/(user)/settings/bookdate/page.tsx
Create new settings page for BookDate:
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function BookDateSettings() {
// Similar structure to BookDateStep, but with:
// - Load existing config on mount
// - Save button instead of Next
// - Clear swipe history button
// - Admin toggle (if user is admin)
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">BookDate Settings</h1>
{/* Same fields as wizard step */}
{/* Add: Clear Swipe History button */}
{/* Add: Admin toggle (if admin role) */}
<button
onClick={handleSave}
className="px-6 py-2 bg-blue-600 text-white rounded-lg"
>
Save Settings
</button>
</div>
);
}
Add to main settings nav:
- Modify
src/app/(user)/settings/page.tsxor settings layout - Add "BookDate" link to settings navigation
Phase 6: BookDate Tab & UI (Priority: Critical)
6.1 BookDate Page
File: src/app/(user)/bookdate/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import RecommendationCard from '@/components/bookdate/RecommendationCard';
import LoadingScreen from '@/components/bookdate/LoadingScreen';
export default function BookDatePage() {
const [recommendations, setRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [lastSwipe, setLastSwipe] = useState<any>(null);
const router = useRouter();
useEffect(() => {
loadRecommendations();
}, []);
const loadRecommendations = async () => {
setLoading(true);
try {
const response = await fetch('/api/bookdate/recommendations');
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
setRecommendations(data.recommendations);
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
const handleSwipe = async (action: 'left' | 'right' | 'up', markedAsKnown = false) => {
const recommendation = recommendations[currentIndex];
try {
await fetch('/api/bookdate/swipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recommendationId: recommendation.id,
action,
markedAsKnown
})
});
setLastSwipe({ recommendation, action });
setCurrentIndex(currentIndex + 1);
// Check if we've reached the end
if (currentIndex + 1 >= recommendations.length) {
// Show empty state
}
} catch (error) {
console.error('Swipe error:', error);
}
};
const handleUndo = async () => {
// Only for left/up swipes
if (!lastSwipe || lastSwipe.action === 'right') return;
try {
const response = await fetch('/api/bookdate/undo', {
method: 'POST'
});
if (response.ok) {
setCurrentIndex(currentIndex - 1);
setLastSwipe(null);
}
} catch (error) {
console.error('Undo error:', error);
}
};
if (loading) {
return <LoadingScreen />;
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold mb-4">⚠️ Could not load recommendations</h2>
<p className="text-gray-600 mb-4">{error}</p>
<div className="flex gap-4">
<button onClick={loadRecommendations} className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Try Again
</button>
<button onClick={() => router.push('/settings/bookdate')} className="px-4 py-2 border rounded-lg">
Go to Settings
</button>
</div>
</div>
);
}
if (currentIndex >= recommendations.length) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold mb-4">🎉 You've seen all our current recommendations!</h2>
<p className="text-gray-600 mb-4">Want more suggestions?</p>
<div className="flex gap-4">
<button onClick={loadRecommendations} className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Get More
</button>
<button onClick={() => router.push('/')} className="px-4 py-2 border rounded-lg">
Go Home
</button>
</div>
</div>
);
}
const currentRec = recommendations[currentIndex];
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<RecommendationCard
recommendation={currentRec}
onSwipe={handleSwipe}
/>
{/* Undo button (show briefly after left/up swipe) */}
{lastSwipe && lastSwipe.action !== 'right' && (
<button
onClick={handleUndo}
className="fixed bottom-8 left-8 px-4 py-2 bg-gray-800 text-white rounded-lg"
>
Undo
</button>
)}
</div>
);
}
6.2 Recommendation Card Component
File: src/components/bookdate/RecommendationCard.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { useSwipeable } from 'react-swipeable'; // Install: npm install react-swipeable
interface RecommendationCardProps {
recommendation: any;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
}
export default function RecommendationCard({ recommendation, onSwipe }: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const handleSwipeRight = () => {
// Show confirmation toast
setShowToast(true);
};
const handleToastAction = (action: 'request' | 'known' | 'cancel') => {
setShowToast(false);
if (action === 'request') {
onSwipe('right', false);
} else if (action === 'known') {
onSwipe('right', true);
}
};
const swipeHandlers = useSwipeable({
onSwipedLeft: () => onSwipe('left'),
onSwipedRight: handleSwipeRight,
onSwipedUp: () => onSwipe('up'),
onSwiping: (eventData) => {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
},
trackMouse: true
});
return (
<>
<div
{...swipeHandlers}
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden"
style={{
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
transition: dragOffset.x === 0 ? 'transform 0.3s' : 'none'
}}
>
{/* Overlay based on drag direction */}
{dragOffset.x > 50 && (
<div className="absolute inset-0 bg-green-500 bg-opacity-30 flex items-center justify-center">
<span className="text-6xl">✅</span>
</div>
)}
{dragOffset.x < -50 && (
<div className="absolute inset-0 bg-red-500 bg-opacity-30 flex items-center justify-center">
<span className="text-6xl">❌</span>
</div>
)}
{dragOffset.y < -50 && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-30 flex items-center justify-center">
<span className="text-6xl">⬆️</span>
</div>
)}
{/* Cover image */}
<div className="w-full h-96 relative">
{recommendation.coverUrl ? (
<Image
src={recommendation.coverUrl}
alt={recommendation.title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
<span className="text-4xl">📚</span>
</div>
)}
</div>
{/* Content */}
<div className="p-6">
<h3 className="text-2xl font-bold mb-2">{recommendation.title}</h3>
<p className="text-gray-600 dark:text-gray-400 mb-1">{recommendation.author}</p>
{recommendation.narrator && (
<p className="text-sm text-gray-500 mb-3">Narrated by {recommendation.narrator}</p>
)}
{recommendation.rating && (
<div className="flex items-center mb-3">
<span className="text-yellow-500">⭐</span>
<span className="ml-1">{recommendation.rating}</span>
</div>
)}
{recommendation.description && (
<p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4">
{recommendation.description}
</p>
)}
</div>
{/* Desktop buttons */}
<div className="hidden md:flex justify-center gap-4 p-6">
<button
onClick={() => onSwipe('left')}
className="px-6 py-3 bg-red-500 text-white rounded-full hover:bg-red-600"
>
❌ Not Interested
</button>
<button
onClick={() => onSwipe('up')}
className="px-6 py-3 bg-blue-500 text-white rounded-full hover:bg-blue-600"
>
⬆️ Dismiss
</button>
<button
onClick={handleSwipeRight}
className="px-6 py-3 bg-green-500 text-white rounded-full hover:bg-green-600"
>
✅ Request
</button>
</div>
</div>
{/* Confirmation Toast */}
{showToast && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md">
<h3 className="text-xl font-bold mb-4">Request "{recommendation.title}"?</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Do you want to request this audiobook, or have you already read/listened to it elsewhere?
</p>
<div className="flex gap-3">
<button
onClick={() => handleToastAction('known')}
className="px-4 py-2 border rounded-lg"
>
Mark as Known
</button>
<button
onClick={() => handleToastAction('request')}
className="px-4 py-2 bg-green-600 text-white rounded-lg"
>
Request
</button>
<button
onClick={() => handleToastAction('cancel')}
className="px-4 py-2 border rounded-lg"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}
6.3 Loading Screen Component
File: src/components/bookdate/LoadingScreen.tsx
export default function LoadingScreen() {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
{/* Animated book cards shuffling */}
<div className="relative w-64 h-96 mb-8">
<div className="absolute inset-0 bg-blue-500 rounded-lg animate-pulse" />
<div className="absolute inset-0 bg-green-500 rounded-lg animate-bounce" style={{ animationDelay: '0.2s' }} />
<div className="absolute inset-0 bg-purple-500 rounded-lg animate-ping" style={{ animationDelay: '0.4s' }} />
</div>
<p className="text-xl text-gray-600 dark:text-gray-400">Finding your next great listen...</p>
</div>
);
}
6.4 Tab Visibility
File: src/components/layout/Header.tsx (or wherever navigation is)
Add conditional BookDate tab:
// Check if user has BookDate configured
const [showBookDate, setShowBookDate] = useState(false);
useEffect(() => {
async function checkBookDate() {
const response = await fetch('/api/bookdate/config');
const data = await response.json();
setShowBookDate(data.config && data.config.isVerified && data.config.isEnabled);
}
checkBookDate();
}, []);
// In navigation:
{showBookDate && (
<Link href="/bookdate">BookDate</Link>
)}
Phase 7: Integration & Polish (Priority: Medium)
7.1 Request Integration
When user swipes right and confirms "Request":
- Call existing request API:
POST /api/requestswith{ asin: recommendation.audnexusAsin } - Show success toast
- Continue to next recommendation
7.2 Plex Library Integration
Implement getUserLibraryBooks() helper:
- Query Plex API for user's audiobook library
- For 'listened' scope: Calculate
viewOffset / duration > 0.25 - For 'rated' scope: Filter books with user ratings
- Return latest 40 books
Reference: See documentation/integrations/plex.md for existing Plex API patterns
7.3 Audnexus Matching
Implement matchToAudnexus() helper:
- Search Audnexus API by title + author
- Fuzzy match if exact fails
- Return metadata or null
Reference: Existing Audible search code or documentation/integrations/audible.md
7.4 Error Logging
Add comprehensive logging:
- API errors
- Audnexus match failures
- AI response parsing errors
7.5 Dark Mode Support
Ensure all components support dark mode:
- Use Tailwind
dark:variants - Test in both modes
Phase 8: Testing & Verification (Priority: High)
8.1 Database Testing
- Run
npx prisma db pushsuccessfully - Verify tables created in PostgreSQL
- Test encrypted API key storage/retrieval
- Test cascade deletes (user deletion removes configs/recommendations/swipes)
8.2 API Testing
- Test connection endpoint with valid/invalid API keys
- Test config save/update/delete
- Test recommendation generation
- Test swipe recording
- Test undo functionality
- Test admin toggle
8.3 UI Testing
- Setup wizard step works (skip functionality)
- Settings page loads and saves config
- BookDate tab shows/hides based on config
- Swipe gestures work on mobile
- Desktop buttons work
- Loading screen displays correctly
- Empty state shows at end
- Undo button appears/works
- Toast confirmation works
8.4 Integration Testing
- Request creation from right swipe
- Plex library data fetched correctly
- Audnexus matching works
- Recommendations exclude library books
- Recommendations exclude already-swiped books
- Cache persistence across sessions
MVP Definition
MVP is complete when:
✅ User can configure BookDate in setup wizard (or skip) ✅ User can configure BookDate in settings page ✅ BookDate tab visible only when configured ✅ User can view AI-generated recommendations ✅ User can swipe (mobile) or click buttons (desktop) ✅ Right swipe shows confirmation toast ✅ Request is created when user confirms ✅ Swipes are recorded and influence future recommendations ✅ Cache works (no duplicate API calls for same recommendations) ✅ Error states handled gracefully ✅ Dark mode supported
Out of scope for MVP:
- Advanced animations
- Swipe statistics/analytics
- Multi-AI voting
- Social features
- Advanced filtering
Implementation Order
Follow this order for fastest path to MVP:
- Database schema (Phase 1) - Foundation
- Config API endpoints (Phase 2) - Test connection, save config
- Recommendation API endpoints (Phase 3) - Core functionality
- Basic UI components (Phase 6) - Card, loading screen
- BookDate page (Phase 6) - Main interface
- Settings page (Phase 5) - Configuration UI
- Setup wizard integration (Phase 4) - Optional setup
- Plex/Audnexus integration (Phase 7) - Real data
- Request integration (Phase 7) - Complete flow
- Polish & testing (Phase 8) - Bug fixes, UX improvements
Key Technical Notes
Encryption
- Use existing encryption utilities for API keys
- Never log decrypted keys
- Pattern:
encrypt()on save,decrypt()on use
Prisma
- Update
prisma/schema.prisma - Run
npx prisma db pushto sync - Run
npx prisma generateto regenerate client - Import from
@/lib/prismaor similar
Auth Middleware
- Use
requireAuth(req)for user endpoints - Use
requireAdmin(req)for admin endpoints - Returns user object or null
API Response Format
// Success
return NextResponse.json({ success: true, data: {...} });
// Error
return NextResponse.json({ error: 'Error message' }, { status: 400 });
Component Patterns
- Use
'use client'directive for client components - Tailwind for styling
- Dark mode:
className="bg-white dark:bg-gray-800" - Loading states: Show skeletons or spinners
Mobile Swipe Library
Install react-swipeable:
npm install react-swipeable
File Headers
Add to all new files:
/**
* BookDate: [Brief description]
* Documentation: documentation/features/bookdate-prd.md
*/
Documentation Updates
After implementation, update:
-
TABLEOFCONTENTS.md - Add BookDate section:
## BookDate (AI Recommendations) - **AI-powered recommendations, swipe interface** → features/bookdate.md - **Configuration, setup wizard integration** → features/bookdate.md -
Create documentation/features/bookdate.md - Token-efficient format:
# BookDate Feature **Status:** ✅ Implemented | AI-powered audiobook recommendations with swipe interface ## Overview Tinder-style recommendation system using OpenAI/Claude APIs, personalized per user. ## Key Details - **Providers:** OpenAI, Claude (Anthropic) - **Scopes:** Full library, listened only (>25%), rated only - **Actions:** Swipe left (reject), right (request), up (dismiss) - **Caching:** Per-user, 10 recommendations cached - **Context:** Max 50 books (40 library + 10 swipes) ## API Endpoints - POST /api/bookdate/test-connection - Validate API key, fetch models - GET/POST/DELETE /api/bookdate/config - Manage user config - GET /api/bookdate/recommendations - Get cached or generate new - POST /api/bookdate/swipe - Record swipe action - POST /api/bookdate/undo - Undo last swipe - PATCH /api/admin/bookdate/toggle - Admin enable/disable ## Database Models - BookDateConfig (per user) - BookDateRecommendation (cached) - BookDateSwipe (history) ## Related: features/bookdate-prd.md (full requirements) -
Update documentation/README.md - Add to features list:
- BookDate: AI-powered recommendations with swipe interface
Questions & Clarifications
If you encounter any blockers or need clarification:
- Check the PRD first:
/home/user/ReadMeABook/documentation/features/bookdate-prd.md - Check relevant docs: Use
TABLEOFCONTENTS.mdto find related documentation - Follow existing patterns: Look at similar features (setup wizard, settings pages, request flow)
- Ask the user: If truly blocked, ask specific technical questions
Success Criteria
You've successfully implemented BookDate MVP when:
- A user can complete setup wizard with BookDate configuration
- A user can view recommendations in BookDate tab
- A user can swipe through recommendations
- A user can request audiobooks from recommendations
- Recommendations improve based on swipe history
- All error states handled gracefully
- Dark mode works
- Mobile and desktop UX both functional
Good luck! Reference the PRD frequently and follow existing code patterns.