mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
50fb5a68af
Introduces support for custom OpenAI-compatible AI providers with configurable base URLs, including UI, backend validation, and connection testing. Enhances qBittorrent integration to support HTTP Basic Auth for reverse proxies, adds detailed debug logging, and updates documentation for both features. Also improves login page description logic and AI prompt generation for recommendations.
225 lines
6.9 KiB
TypeScript
225 lines
6.9 KiB
TypeScript
/**
|
|
* 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';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
|
|
const logger = RMABLogger.create('API.BookDateConfig');
|
|
|
|
// 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) {
|
|
logger.error('Get config error', { error: error instanceof Error ? error.message : String(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, baseUrl, libraryScope, customPrompt, isEnabled } = body;
|
|
|
|
// Check if config exists
|
|
const existingConfig = await prisma.bookDateConfig.findFirst();
|
|
|
|
// Validation - API key only required for new configs (except custom provider)
|
|
if (!existingConfig && !apiKey && provider !== 'custom') {
|
|
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', 'custom'].includes(provider)) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Custom provider requires baseUrl
|
|
if (provider === 'custom') {
|
|
if (!baseUrl) {
|
|
return NextResponse.json(
|
|
{ error: 'Base URL is required for custom provider' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Validate URL format
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid base URL. Must use http:// or https://' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid base URL format' },
|
|
{ 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 (provider === 'custom' && !apiKey && !existingConfig) {
|
|
// Custom provider with no API key (local model) - encrypt empty string
|
|
const encryptionService = getEncryptionService();
|
|
encryptedApiKeyToUse = encryptionService.encrypt('');
|
|
} else if (existingConfig) {
|
|
// No new API key, use existing one
|
|
encryptedApiKeyToUse = existingConfig.apiKey;
|
|
} else {
|
|
// API key required for OpenAI/Claude
|
|
return NextResponse.json(
|
|
{ error: 'API key is required' },
|
|
{ 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;
|
|
}
|
|
|
|
// Update or clear baseUrl based on provider
|
|
if (provider === 'custom') {
|
|
updateData.baseUrl = baseUrl;
|
|
} else {
|
|
updateData.baseUrl = null; // Clear baseUrl when switching away from custom
|
|
}
|
|
|
|
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,
|
|
baseUrl: provider === 'custom' ? baseUrl : null,
|
|
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) {
|
|
logger.error('Save config error', { error: error instanceof Error ? error.message : String(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) {
|
|
logger.error('Delete config error', { error: error instanceof Error ? error.message : String(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));
|
|
}
|