diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 4a57a69b3..5333f685b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,130 +1,79 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; -import { decrypt } from "@/lib/crypto"; import { extractFeatures } from "@/lib/insights/features"; -import { - INSIGHTS_SYSTEM_PROMPT, - buildUserPrompt, - type InsightsOutput, -} from "@/lib/insights/prompt"; +import { INSIGHTS_SYSTEM_PROMPT, buildUserPrompt } from "@/lib/insights/prompt"; +import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; +import { resolveProvider } from "@/lib/ai/provider"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -/** - * Generate AI-powered health insights. - * Rate limit: 2 per hour per user. - * Caches result daily (or until new data arrives). - */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const userId = user.id; - // Rate limit: 2 per hour per user const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000); if (!rl.allowed) { - return apiError( - "Maximum 2 insight generations per hour. Please try again later.", - 429, - ); + return apiError("Maximum 2 insight generations per hour.", 429); } const dbUser = await prisma.user.findUnique({ where: { id: userId }, select: { - openaiKeyEncrypted: true, insightsPrivacyMode: true, insightsCachedAt: true, insightsCachedText: true, }, }); - if (!dbUser?.openaiKeyEncrypted) { - return apiError( - "No OpenAI API key configured. Please set it in settings.", - 422, - ); - } - - // Check if force refresh is requested const body = await request.json().catch(() => ({})); const forceRefresh = body.force === true; - // Return cached result if available and less than 24h old if ( !forceRefresh && - dbUser.insightsCachedAt && + dbUser?.insightsCachedAt && dbUser.insightsCachedText && Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 ) { try { - const cached = JSON.parse(dbUser.insightsCachedText) as InsightsOutput; + const cached = JSON.parse(dbUser.insightsCachedText); annotate({ action: { name: "insights.generate" }, meta: { cached: true } }); - return apiSuccess({ - insights: cached, - cached: true, - cachedAt: dbUser.insightsCachedAt, - }); + return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt }); } catch { // Invalid cache, regenerate } } - const apiKey = decrypt(dbUser.openaiKeyEncrypted); - const includeRaw = dbUser.insightsPrivacyMode === "raw"; + const provider = await resolveProvider(userId); + + if (provider.type === "none") { + return apiError("No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422); + } + + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; const features = await extractFeatures(userId, includeRaw); const featuresJson = JSON.stringify(features, null, 2); - const userPrompt = buildUserPrompt(featuresJson, dbUser.insightsPrivacyMode); + const userPrompt = buildUserPrompt(featuresJson, dbUser?.insightsPrivacyMode ?? "aggregated"); - // Call OpenAI - const openaiRes = await fetch( - "https://api.openai.com/v1/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: "gpt-4o-mini", - messages: [ - { role: "system", content: INSIGHTS_SYSTEM_PROMPT }, - { role: "user", content: userPrompt }, - ], - temperature: 0.3, - max_tokens: 1000, - response_format: { type: "json_object" }, - }), - }, - ); + const result = await provider.generateCompletion({ + systemPrompt: INSIGHTS_SYSTEM_PROMPT, + userPrompt, + temperature: 0.3, + maxTokens: 1500, + }); - if (!openaiRes.ok) { - const errBody = await openaiRes.text(); - annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } }); - if (openaiRes.status === 401) { - return apiError("Invalid OpenAI API key", 422); - } - return apiError("OpenAI request failed", 502); - } - - const openaiJson = await openaiRes.json(); - const content = openaiJson.choices?.[0]?.message?.content; - - if (!content) { - return apiError("No response from OpenAI", 502); - } - - let insights: InsightsOutput; + let insights: InsightResult | Record; try { - insights = JSON.parse(content); + const parsed = JSON.parse(result.content); + // Try new schema first, fall back to raw parsed if validation fails + const validated = insightResultSchema.safeParse(parsed); + insights = validated.success ? validated.data : parsed; } catch { - return apiError("Failed to parse OpenAI response", 502); + return apiError("Failed to parse AI response", 502); } - // Cache the result await prisma.user.update({ where: { id: userId }, data: { @@ -133,13 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // Audit log (no sensitive content) await auditLog("insights.generate", { userId, ipAddress: getClientIp(request), details: { - privacyMode: dbUser.insightsPrivacyMode, - tokensUsed: openaiJson.usage?.total_tokens ?? null, + privacyMode: dbUser?.insightsPrivacyMode, + tokensUsed: result.tokensUsed, + providerType: result.providerType, + model: result.model, }, }); @@ -147,8 +97,9 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "insights.generate" }, meta: { cached: false, - privacyMode: dbUser.insightsPrivacyMode, - tokensUsed: openaiJson.usage?.total_tokens ?? null, + providerType: result.providerType, + model: result.model, + tokensUsed: result.tokensUsed, }, }); diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index f2ceb2682..728c4f010 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -1,72 +1,52 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; -import { encrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -/** - * Get insights configuration status. - */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { - openaiKeyEncrypted: true, + codexConnectionStatus: true, + codexConnectedAt: true, insightsPrivacyMode: true, insightsCachedAt: true, }, }); + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + annotate({ action: { name: "insights.settings.get" } }); return apiSuccess({ - hasKey: !!dbUser?.openaiKeyEncrypted, + codexStatus: dbUser?.codexConnectionStatus ?? "disconnected", + codexConnectedAt: dbUser?.codexConnectedAt ?? null, + hasAdminKey: !!settings?.adminAiKeyEncrypted, privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated", lastInsightAt: dbUser?.insightsCachedAt ?? null, }); }); -/** - * Update insights settings (API key and/or privacy mode). - */ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson>(request); - if (jsonError) return jsonError; + const data: Record = {}; - // Update API key if provided - if (typeof body.apiKey === "string") { - const key = (body.apiKey as string).trim(); - if (key === "") { - // Remove key - data.openaiKeyEncrypted = null; - data.insightsCachedAt = null; - data.insightsCachedText = null; - } else { - if (!key.startsWith("sk-")) { - return apiError( - "Invalid API key format (must start with sk-)", - 422, - ); - } - data.openaiKeyEncrypted = encrypt(key); - } - } - - // Update privacy mode if provided if (typeof body.privacyMode === "string") { const mode = body.privacyMode as string; if (!["aggregated", "raw"].includes(mode)) { return apiError("Invalid privacy mode", 422); } data.insightsPrivacyMode = mode; - // Clear cache when privacy mode changes data.insightsCachedAt = null; data.insightsCachedText = null; } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index db166f87b..3502edbf1 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -18,7 +18,7 @@ class NoProvider implements AIProvider { } async function resolveAdminProvider(): Promise { - const settings = await prisma.appSettings.findUnique({ where: { id: 1 } }); + const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } }); if (settings?.adminAiKeyEncrypted) { return new OpenAIClient({