refactor(insights): use provider abstraction in settings and generate routes

Replace direct OpenAI API calls with resolveProvider() abstraction.
Settings route now returns codexStatus/codexConnectedAt/hasAdminKey
instead of hasKey. Generate route validates AI response with
insightResultSchema and logs providerType/model. Also fixes
AppSettings lookup id from 1 to "singleton" in provider.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent fde3a0941a
commit 249a2c74ba
3 files changed
+46 -115

No files matched your search

+34 -83
View File
@@ -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<string, unknown>;
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,
},
});
+11 -31
View File
@@ -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<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const data: Record<string, unknown> = {};
// 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;
}
+1 -1
View File
@@ -18,7 +18,7 @@ class NoProvider implements AIProvider {
}
async function resolveAdminProvider(): Promise<AIProvider> {
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
if (settings?.adminAiKeyEncrypted) {
return new OpenAIClient({