Files
CapaKraken/packages/api/src/router/settings.ts
T

442 lines
17 KiB
TypeScript

import { z } from "zod";
import { adminProcedure, createTRPCRouter } from "../trpc.js";
import { isAiConfigured, parseAiError, sanitizeDiagnosticError } from "../ai-client.js";
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
import { testSmtpConnection } from "../lib/email.js";
import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.js";
import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
/** Fields that must never appear in audit log values */
const SENSITIVE_FIELDS = new Set([
"azureOpenAiApiKey",
"smtpPassword",
"azureDalleApiKey",
"anonymizationSeed",
"geminiApiKey",
]);
export const settingsRouter = createTRPCRouter({
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const runtimeSettings = resolveSystemSettingsRuntime(settings);
const runtimeSecrets = getRuntimeSecretStatuses(settings);
const legacyStoredSecretFields = RUNTIME_SECRET_FIELDS.filter(
(field) => runtimeSecrets[field].hasStoredValue,
);
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
return {
aiProvider: settings?.aiProvider ?? "openai",
azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null,
azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null,
azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview",
aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300,
aiTemperature: settings?.aiTemperature ?? 1,
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
hasApiKey: !!runtimeSettings.azureOpenAiApiKey,
runtimeSecrets,
legacyStoredSecretFields,
scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights,
scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"],
// SMTP
smtpHost: settings?.smtpHost ?? null,
smtpPort: settings?.smtpPort ?? 587,
smtpUser: settings?.smtpUser ?? null,
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!runtimeSettings.smtpPassword,
// Global anonymization
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
anonymizationMode: settings?.anonymizationMode ?? "global",
// DALL-E
azureDalleDeployment: settings?.azureDalleDeployment ?? null,
azureDalleEndpoint: settings?.azureDalleEndpoint ?? null,
hasDalleApiKey: !!runtimeSettings.azureDalleApiKey,
// Gemini
geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image",
hasGeminiApiKey: !!runtimeSettings.geminiApiKey,
// Image provider
imageProvider: settings?.imageProvider ?? "dalle",
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
// Timeline
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
};
}),
updateSystemSettings: adminProcedure
.input(
z.object({
aiProvider: z.enum(["openai", "azure"]).optional(),
azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")),
azureOpenAiDeployment: z.string().optional(),
azureOpenAiApiKey: z.string().optional(),
azureApiVersion: z.string().optional(),
aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(),
aiTemperature: z.number().min(0).max(2).optional(),
aiSummaryPrompt: z.string().optional(),
scoreWeights: z.object({
skillDepth: z.number().min(0).max(1),
skillBreadth: z.number().min(0).max(1),
costEfficiency: z.number().min(0).max(1),
chargeability: z.number().min(0).max(1),
experience: z.number().min(0).max(1),
}).refine(
(w) => {
const sum = w.skillDepth + w.skillBreadth + w.costEfficiency + w.chargeability + w.experience;
return Math.abs(sum - 1.0) < 0.01;
},
{ message: "Score weights must sum to 1.0" },
).optional(),
scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).optional(),
// SMTP
smtpHost: z.string().optional(),
smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().optional(),
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
// Global anonymization
anonymizationEnabled: z.boolean().optional(),
anonymizationDomain: z.string().trim().min(1).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(),
// DALL-E image generation
azureDalleDeployment: z.string().optional(),
azureDalleEndpoint: z.string().url().optional().or(z.literal("")),
azureDalleApiKey: z.string().optional(),
// Gemini image generation
geminiApiKey: z.string().optional(),
geminiModel: z.string().optional(),
// Image provider selection
imageProvider: z.enum(["dalle", "gemini"]).optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
// Timeline
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {};
const ignoredSecretFields: string[] = [];
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
if (input.azureOpenAiEndpoint !== undefined)
data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
if (input.azureOpenAiDeployment !== undefined)
data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
if (input.azureOpenAiApiKey !== undefined)
ignoredSecretFields.push("azureOpenAiApiKey");
if (input.azureApiVersion !== undefined)
data.azureApiVersion = input.azureApiVersion || null;
if (input.aiMaxCompletionTokens !== undefined)
data.aiMaxCompletionTokens = input.aiMaxCompletionTokens;
if (input.aiTemperature !== undefined)
data.aiTemperature = input.aiTemperature;
if (input.aiSummaryPrompt !== undefined)
data.aiSummaryPrompt = input.aiSummaryPrompt || null;
if (input.scoreWeights !== undefined)
data.scoreWeights = input.scoreWeights;
if (input.scoreVisibleRoles !== undefined)
data.scoreVisibleRoles = input.scoreVisibleRoles;
// SMTP
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null;
if (input.smtpPassword !== undefined) ignoredSecretFields.push("smtpPassword");
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
// Global anonymization
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
if (input.anonymizationSeed !== undefined) ignoredSecretFields.push("anonymizationSeed");
if (input.anonymizationMode !== undefined) {
data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null;
}
// DALL-E
if (input.azureDalleDeployment !== undefined)
data.azureDalleDeployment = input.azureDalleDeployment || null;
if (input.azureDalleEndpoint !== undefined)
data.azureDalleEndpoint = input.azureDalleEndpoint || null;
if (input.azureDalleApiKey !== undefined)
ignoredSecretFields.push("azureDalleApiKey");
// Gemini
if (input.geminiApiKey !== undefined)
ignoredSecretFields.push("geminiApiKey");
if (input.geminiModel !== undefined)
data.geminiModel = input.geminiModel || null;
// Image provider
if (input.imageProvider !== undefined)
data.imageProvider = input.imageProvider;
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
// Timeline
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
if (Object.keys(data).length === 0) {
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}
// Fetch current settings for before-snapshot
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...data },
update: data,
});
// Build sanitized snapshots — redact sensitive fields
const sanitize = (obj: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value;
}
return result;
};
const sanitizedBefore = before ? sanitize(before as unknown as Record<string, unknown>) : undefined;
const sanitizedAfter = sanitize(data);
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "System Settings",
action: before ? "UPDATE" : "CREATE",
userId: ctx.dbUser?.id,
...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}),
after: sanitizedAfter,
source: "ui",
});
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}),
clearStoredRuntimeSecrets: adminProcedure.mutation(async ({ ctx }) => {
const existing = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: Object.fromEntries(
RUNTIME_SECRET_FIELDS.map((field) => [field, true]),
) as Record<(typeof RUNTIME_SECRET_FIELDS)[number], true>,
});
const clearedFields = RUNTIME_SECRET_FIELDS.filter((field) => !!existing?.[field]);
if (clearedFields.length === 0) {
return { ok: true, clearedFields: [] as string[] };
}
await ctx.db.systemSettings.update({
where: { id: "singleton" },
data: Object.fromEntries(clearedFields.map((field) => [field, null])),
});
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Runtime Secrets",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { clearedFields },
source: "ui",
summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`,
});
return { ok: true, clearedFields };
}),
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
}));
if (!isAiConfigured(settings)) {
const provider = settings?.aiProvider ?? "openai";
if (provider === "azure") {
return { ok: false, error: "Missing required fields: endpoint, deployment name, and API key are all required for Azure OpenAI." };
}
return { ok: false, error: "Missing required fields: model name and API key are required." };
}
const provider = settings.aiProvider ?? "openai";
const apiKey = settings.azureOpenAiApiKey!;
let url: string;
let headers: Record<string, string>;
if (provider === "azure") {
const endpoint = settings.azureOpenAiEndpoint!.replace(/\/$/, "");
const deployment = settings.azureOpenAiDeployment!;
const apiVersion = settings.azureApiVersion ?? "2025-01-01-preview";
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
headers = { "Content-Type": "application/json", "api-key": apiKey };
} else {
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
const model = settings.azureOpenAiDeployment ?? "gpt-4o-mini";
url = "https://api.openai.com/v1/chat/completions";
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
// Override body to include model field for OpenAI
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) return { ok: true };
let msg = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
if (parsed.error?.message) msg = parsed.error.message;
} catch { /* keep raw */ }
const raw = `HTTP ${resp.status}: ${msg}`;
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(raw) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(new Error(raw)) };
} catch (err) {
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(err) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(err) };
}
}
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) return { ok: true };
let azureMessage = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string; code?: string } };
if (parsed.error?.message) azureMessage = parsed.error.message;
} catch { /* leave as raw text */ }
const raw = `HTTP ${resp.status}: ${azureMessage}`;
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(raw) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(new Error(raw)) };
} catch (err) {
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(err) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(err) };
}
}),
testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => {
const result = await testSmtpConnection();
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "SMTP Connection Test",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { testResult: result.ok ? "success" : "failed" },
source: "ui",
summary: result.ok ? "SMTP connection test succeeded" : "SMTP connection test failed",
});
return result;
}),
testGeminiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: { geminiApiKey: true, geminiModel: true },
}));
if (!settings?.geminiApiKey) {
return { ok: false, error: "Gemini API key is not configured." };
}
try {
const { generateGeminiImage } = await import("../gemini-client.js");
const model = settings.geminiModel ?? "gemini-2.5-flash-image";
// Generate a tiny test image with a simple prompt
const dataUrl = await generateGeminiImage(
settings.geminiApiKey,
"A simple blue circle on white background, minimal, 256x256",
model,
);
const hasImage = dataUrl.startsWith("data:image/");
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Gemini Connection Test",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { testResult: hasImage ? "success" : "failed" },
source: "ui",
summary: hasImage ? "Gemini image generation test succeeded" : "Gemini test returned no image",
});
return { ok: hasImage, model, preview: hasImage ? dataUrl.slice(0, 100) + "..." : undefined };
} catch (err) {
const { parseGeminiError } = await import("../gemini-client.js");
return { ok: false, error: parseGeminiError(err) };
}
}),
getAiConfigured: adminProcedure.query(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
}));
return { configured: isAiConfigured(settings) };
}),
});