mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* BookDate: User Configuration Management
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
|
||||
// GET: Fetch global BookDate configuration (excluding API key)
|
||||
// Any authenticated user can check if BookDate is configured
|
||||
async function getConfig(req: AuthenticatedRequest) {
|
||||
try {
|
||||
// Get the single global config (there should only be one record)
|
||||
const config = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ config: null });
|
||||
}
|
||||
|
||||
// Don't return API key for security
|
||||
const { apiKey, ...safeConfig } = config;
|
||||
|
||||
return NextResponse.json({ config: safeConfig });
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Get config error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create or update global BookDate configuration (Admin only)
|
||||
async function saveConfig(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, model, libraryScope, customPrompt, isEnabled } = body;
|
||||
|
||||
// Check if config exists
|
||||
const existingConfig = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
// Validation - API key only required for new configs
|
||||
if (!existingConfig && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for initial setup' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!provider || !model) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: provider, model' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which API key to use
|
||||
let encryptedApiKeyToUse: string;
|
||||
|
||||
if (apiKey) {
|
||||
// New API key provided - encrypt it
|
||||
const encryptionService = getEncryptionService();
|
||||
encryptedApiKeyToUse = encryptionService.encrypt(apiKey);
|
||||
} else if (existingConfig) {
|
||||
// No new API key, use existing one
|
||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||
} else {
|
||||
// This shouldn't happen due to validation above, but just in case
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for new configuration' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let config;
|
||||
if (existingConfig) {
|
||||
// Update existing config
|
||||
const updateData: any = {
|
||||
provider,
|
||||
model,
|
||||
isEnabled: isEnabled !== undefined ? isEnabled : true,
|
||||
isVerified: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Only update API key if a new one was provided
|
||||
if (apiKey) {
|
||||
updateData.apiKey = encryptedApiKeyToUse;
|
||||
}
|
||||
|
||||
config = await prisma.bookDateConfig.update({
|
||||
where: { id: existingConfig.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
// Create new global config
|
||||
// Note: libraryScope and customPrompt are now per-user settings (deprecated in global config)
|
||||
config = await prisma.bookDateConfig.create({
|
||||
data: {
|
||||
provider,
|
||||
model,
|
||||
libraryScope: 'full', // Default value for backwards compatibility
|
||||
customPrompt: null,
|
||||
isEnabled: isEnabled !== undefined ? isEnabled : true,
|
||||
isVerified: true,
|
||||
apiKey: encryptedApiKeyToUse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clear ALL users' cached recommendations when global config changes
|
||||
await prisma.bookDateRecommendation.deleteMany({});
|
||||
|
||||
// Return config without API key
|
||||
const { apiKey: _, ...safeConfig } = config;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: safeConfig,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Save config error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to save configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove global BookDate configuration (Admin only)
|
||||
async function deleteConfig(req: AuthenticatedRequest) {
|
||||
try {
|
||||
// Get the global config
|
||||
const config = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete global configuration
|
||||
await prisma.bookDateConfig.delete({
|
||||
where: { id: config.id },
|
||||
});
|
||||
|
||||
// Also delete ALL cached recommendations and swipe history
|
||||
await prisma.bookDateRecommendation.deleteMany({});
|
||||
await prisma.bookDateSwipe.deleteMany({});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Delete config error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to delete configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return requireAuth(req, getConfig);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return requireAuth(req, async (authReq) => requireAdmin(authReq, saveConfig));
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return requireAuth(req, async (authReq) => requireAdmin(authReq, deleteConfig));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* BookDate: Force Generate New Recommendations
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import {
|
||||
buildAIPrompt,
|
||||
callAI,
|
||||
matchToAudnexus,
|
||||
isInLibrary,
|
||||
isAlreadyRequested,
|
||||
isAlreadySwiped,
|
||||
} from '@/lib/bookdate/helpers';
|
||||
|
||||
async function handler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Get global config
|
||||
const config = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (!config || !config.isVerified || !config.isEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateCustomPrompt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build user preferences object
|
||||
const userPreferences = {
|
||||
libraryScope: user.bookDateLibraryScope || 'full',
|
||||
customPrompt: user.bookDateCustomPrompt || null,
|
||||
};
|
||||
|
||||
// Build prompt and call AI (same as recommendations endpoint, but doesn't check cache)
|
||||
console.log('[BookDate] Force generating new recommendations for user:', userId);
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
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: missing recommendations array');
|
||||
}
|
||||
|
||||
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
|
||||
|
||||
// Match to Audnexus and filter
|
||||
const batchId = `batch_${Date.now()}`;
|
||||
const matched: any[] = [];
|
||||
|
||||
for (const rec of aiResponse.recommendations) {
|
||||
if (!rec.title || !rec.author) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already swiped
|
||||
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if in library
|
||||
if (await isInLibrary(userId, rec.title, rec.author)) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Check again if in library with ASIN for exact matching
|
||||
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
|
||||
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
|
||||
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already requested
|
||||
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matched.push({
|
||||
userId,
|
||||
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 || 'Recommended based on your preferences',
|
||||
});
|
||||
|
||||
if (matched.length >= 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BookDate] Matched ${matched.length} new recommendations`);
|
||||
|
||||
if (matched.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not find any new recommendations. Try adjusting your settings or check back later.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await prisma.bookDateRecommendation.createMany({
|
||||
data: matched,
|
||||
});
|
||||
|
||||
// Return all cached recommendations (excluding swiped ones)
|
||||
const allRecommendations = await prisma.bookDateRecommendation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// Exclude recommendations that have associated swipes
|
||||
swipes: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
recommendations: allRecommendations,
|
||||
source: 'generated',
|
||||
generatedCount: matched.length,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Generate error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || 'Failed to generate new recommendations',
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return requireAuth(req, handler);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: BookDate User Preferences API
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* GET /api/bookdate/preferences
|
||||
* Get current user's BookDate preferences (library scope and custom prompt)
|
||||
*/
|
||||
async function getPreferences(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Get user preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
libraryScope: user.bookDateLibraryScope || 'full',
|
||||
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: user.bookDateOnboardingComplete || false,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Get BookDate preferences error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to get preferences' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/bookdate/preferences
|
||||
* Update current user's BookDate preferences
|
||||
*/
|
||||
async function updatePreferences(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Parse request body
|
||||
const body = await req.json();
|
||||
const { libraryScope, customPrompt, onboardingComplete } = body;
|
||||
|
||||
// Validate library scope
|
||||
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid library scope. Must be "full" or "rated"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate custom prompt length (only if provided and not empty)
|
||||
if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Custom prompt must be 1000 characters or less' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update data object
|
||||
const updateData: any = {};
|
||||
if (libraryScope !== undefined) {
|
||||
updateData.bookDateLibraryScope = libraryScope || 'full';
|
||||
}
|
||||
if (customPrompt !== undefined) {
|
||||
// Normalize empty strings to null for consistency
|
||||
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
|
||||
updateData.bookDateCustomPrompt = normalizedPrompt;
|
||||
}
|
||||
if (onboardingComplete !== undefined) {
|
||||
updateData.bookDateOnboardingComplete = onboardingComplete;
|
||||
}
|
||||
|
||||
// Update user preferences
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
libraryScope: updatedUser.bookDateLibraryScope || 'full',
|
||||
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Update BookDate preferences error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to update preferences' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return requireAuth(req, getPreferences);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
return requireAuth(req, updatePreferences);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* BookDate: Get Recommendations
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import {
|
||||
buildAIPrompt,
|
||||
callAI,
|
||||
matchToAudnexus,
|
||||
isInLibrary,
|
||||
isAlreadyRequested,
|
||||
isAlreadySwiped,
|
||||
} from '@/lib/bookdate/helpers';
|
||||
|
||||
async function handler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Check for cached recommendations (exclude any that have been swiped)
|
||||
const cached = await prisma.bookDateRecommendation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// Exclude recommendations that have associated swipes
|
||||
swipes: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// If there are any cached unswiped recommendations, return them
|
||||
if (cached.length > 0) {
|
||||
return NextResponse.json({
|
||||
recommendations: cached,
|
||||
source: 'cache',
|
||||
remaining: cached.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Need to generate new recommendations - fetch global config
|
||||
const config = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (!config || !config.isVerified || !config.isEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateCustomPrompt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build user preferences object
|
||||
const userPreferences = {
|
||||
libraryScope: user.bookDateLibraryScope || 'full',
|
||||
customPrompt: user.bookDateCustomPrompt || null,
|
||||
};
|
||||
|
||||
// Build prompt and call AI
|
||||
console.log('[BookDate] Generating new recommendations for user:', userId);
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
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: missing recommendations array');
|
||||
}
|
||||
|
||||
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
|
||||
|
||||
// Match to Audnexus and filter
|
||||
const batchId = `batch_${Date.now()}`;
|
||||
const matched: any[] = [];
|
||||
|
||||
for (const rec of aiResponse.recommendations) {
|
||||
if (!rec.title || !rec.author) {
|
||||
console.warn('[BookDate] Skipping recommendation with missing title or author');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already swiped
|
||||
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
|
||||
console.log(`[BookDate] Skipping already swiped: "${rec.title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if in library
|
||||
if (await isInLibrary(userId, rec.title, rec.author)) {
|
||||
console.log(`[BookDate] Skipping already in library: "${rec.title}"`);
|
||||
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;
|
||||
}
|
||||
|
||||
// Check again if in library with ASIN for exact matching
|
||||
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
|
||||
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
|
||||
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already requested
|
||||
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
|
||||
console.log(`[BookDate] Skipping already requested: "${rec.title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
matched.push({
|
||||
userId,
|
||||
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 || 'Recommended based on your preferences',
|
||||
});
|
||||
|
||||
if (matched.length >= 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BookDate] Matched ${matched.length} recommendations`);
|
||||
|
||||
// Save to database
|
||||
if (matched.length > 0) {
|
||||
await prisma.bookDateRecommendation.createMany({
|
||||
data: matched,
|
||||
});
|
||||
}
|
||||
|
||||
// Combine with existing cache (exclude swiped recommendations)
|
||||
const allRecommendations = await prisma.bookDateRecommendation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
swipes: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
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 || 'Failed to generate recommendations',
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return requireAuth(req, handler);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* BookDate: Record Swipe Action
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
async function handler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const body = await req.json();
|
||||
const { recommendationId, action, markedAsKnown } = body;
|
||||
|
||||
// Validation
|
||||
if (!recommendationId || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: 'recommendationId and action are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['left', 'right', 'up'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be "left", "right", or "up"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get recommendation
|
||||
const recommendation = await prisma.bookDateRecommendation.findUnique({
|
||||
where: { id: recommendationId },
|
||||
});
|
||||
|
||||
if (!recommendation || recommendation.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Recommendation not found or does not belong to user' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Record swipe (keep recommendation in database for undo functionality)
|
||||
await prisma.bookDateSwipe.create({
|
||||
data: {
|
||||
userId,
|
||||
recommendationId,
|
||||
bookTitle: recommendation.title,
|
||||
bookAuthor: recommendation.author,
|
||||
action,
|
||||
markedAsKnown: markedAsKnown || false,
|
||||
},
|
||||
});
|
||||
|
||||
// NOTE: We no longer delete the recommendation here.
|
||||
// This allows undo to work properly by keeping all the original data.
|
||||
// The recommendations endpoint filters out swiped cards.
|
||||
|
||||
// If swiped right and not marked as known, create request
|
||||
if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) {
|
||||
try {
|
||||
// Check if book already exists in audiobooks table
|
||||
let audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: recommendation.audnexusAsin },
|
||||
});
|
||||
|
||||
// If not, create it
|
||||
if (!audiobook) {
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: recommendation.audnexusAsin,
|
||||
title: recommendation.title,
|
||||
author: recommendation.author,
|
||||
narrator: recommendation.narrator,
|
||||
description: recommendation.description,
|
||||
coverArtUrl: recommendation.coverUrl,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create request (if not already exists)
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
status: 'pending',
|
||||
priority: 0,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[BookDate] Created request for "${recommendation.title}"`);
|
||||
|
||||
// Trigger search job (same as regular request creation)
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
});
|
||||
|
||||
console.log(`[BookDate] Triggered search job for request ${newRequest.id}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BookDate] Error creating request:', error);
|
||||
// Don't fail the swipe if request creation fails
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action,
|
||||
markedAsKnown,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Swipe error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to record swipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return requireAuth(req, handler);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* BookDate: Clear Swipe History (Admin Only)
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
// DELETE: Clear all users' swipe history (Admin only)
|
||||
async function clearSwipes(req: AuthenticatedRequest) {
|
||||
try {
|
||||
// Delete all swipes for ALL users (global admin action)
|
||||
await prisma.bookDateSwipe.deleteMany({});
|
||||
|
||||
// Also clear all cached recommendations (since swipe history affects recommendations)
|
||||
await prisma.bookDateRecommendation.deleteMany({});
|
||||
|
||||
console.log('[BookDate] Admin cleared all swipe history and recommendations');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'All swipe history cleared',
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Clear swipes error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to clear swipe history' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return requireAuth(req, async (authReq) => requireAdmin(authReq, clearSwipes));
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* BookDate: Test AI Provider Connection & Fetch Models
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
|
||||
async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
|
||||
// Validate provider
|
||||
if (!provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Provider is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get API key from saved global config if useSavedKey is true
|
||||
let testApiKey = apiKey;
|
||||
if (useSavedKey && !testApiKey) {
|
||||
const { prisma } = await import('@/lib/db');
|
||||
const { getEncryptionService } = await import('@/lib/services/encryption.service');
|
||||
|
||||
const config = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
if (!config || !config.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No saved API key found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
testApiKey = encryptionService.decrypt(config.apiKey);
|
||||
}
|
||||
|
||||
if (!testApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let models = [];
|
||||
|
||||
if (provider === 'openai') {
|
||||
// OpenAI: Fetch models from API
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${testApiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] OpenAI API error:', errorText);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid OpenAI API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to relevant GPT models
|
||||
models = data.data
|
||||
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.id,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': testApiKey,
|
||||
'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: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] Claude API error:', errorText);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models,
|
||||
provider,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Test connection error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Connection test failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unauthenticated handler for setup wizard
|
||||
async function unauthenticatedHandler(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
|
||||
// During setup, useSavedKey should not be used (no auth context)
|
||||
if (useSavedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required to use saved API key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
if (!provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Provider is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let models = [];
|
||||
|
||||
if (provider === 'openai') {
|
||||
// OpenAI: Fetch models from API
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] OpenAI API error:', errorText);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid OpenAI API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to relevant GPT models
|
||||
models = data.data
|
||||
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.id,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// 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: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] Claude API error:', errorText);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models,
|
||||
provider,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Test connection error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Connection test failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Check if request has authorization header
|
||||
const authHeader = req.headers.get('authorization');
|
||||
|
||||
if (authHeader) {
|
||||
// Authenticated request (from settings page)
|
||||
return requireAuth(req, authenticatedHandler);
|
||||
} else {
|
||||
// Unauthenticated request (from setup wizard)
|
||||
return unauthenticatedHandler(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* BookDate: Undo Last Swipe
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
async function handler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Get last swipe (left or up only - can't undo right swipes)
|
||||
const lastSwipe = await prisma.bookDateSwipe.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
action: {
|
||||
in: ['left', 'up'],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
recommendation: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lastSwipe) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No swipe to undo' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!lastSwipe.recommendation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Recommendation no longer exists' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the oldest existing unswiped recommendation to determine where to insert
|
||||
const oldestRecommendation = await prisma.bookDateRecommendation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
swipes: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Set createdAt to be before the oldest recommendation (so it appears at the front)
|
||||
// If no recommendations exist, set it to 1 day ago
|
||||
const undoCreatedAt = oldestRecommendation
|
||||
? new Date(oldestRecommendation.createdAt.getTime() - 1000) // 1 second before oldest
|
||||
: new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago if none exist
|
||||
|
||||
// Delete the swipe (this makes the recommendation visible again)
|
||||
await prisma.bookDateSwipe.delete({
|
||||
where: { id: lastSwipe.id },
|
||||
});
|
||||
|
||||
// Update the recommendation's createdAt to put it at the front of the stack
|
||||
const restoredRecommendation = await prisma.bookDateRecommendation.update({
|
||||
where: { id: lastSwipe.recommendation.id },
|
||||
data: {
|
||||
createdAt: undoCreatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
recommendation: restoredRecommendation,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Undo error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to undo swipe' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return requireAuth(req, handler);
|
||||
}
|
||||
Reference in New Issue
Block a user