Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+183
View File
@@ -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));
}
+179
View File
@@ -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);
}
+125
View File
@@ -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);
}
+137
View File
@@ -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);
}
+37
View File
@@ -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);
}
}
+90
View File
@@ -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);
}