diff --git a/packages/api/src/router/settings-support.ts b/packages/api/src/router/settings-support.ts new file mode 100644 index 0000000..abb9621 --- /dev/null +++ b/packages/api/src/router/settings-support.ts @@ -0,0 +1,220 @@ +import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared"; +import { z } from "zod"; +import { + isAiConfigured, + parseAiError, + sanitizeDiagnosticError, +} from "../ai-client.js"; +import { logger } from "../lib/logger.js"; + +/** Fields that must never appear in audit log values */ +const SENSITIVE_FIELDS = new Set([ + "azureOpenAiApiKey", + "smtpPassword", + "azureDalleApiKey", + "anonymizationSeed", + "geminiApiKey", +]); + +export const settingsUpdateInputSchema = 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( + (weights) => { + const sum = weights.skillDepth + + weights.skillBreadth + + weights.costEfficiency + + weights.chargeability + + weights.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(), + 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(), + 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(), + azureDalleDeployment: z.string().optional(), + azureDalleEndpoint: z.string().url().optional().or(z.literal("")), + azureDalleApiKey: z.string().optional(), + geminiApiKey: z.string().optional(), + geminiModel: z.string().optional(), + imageProvider: z.enum(["dalle", "gemini"]).optional(), + vacationDefaultDays: z.number().int().min(0).max(365).optional(), + timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(), +}); + +export type SettingsUpdateInput = z.infer; + +export function getDefaultScoreWeights() { + return { + 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, + }; +} + +export function buildSettingsUpdatePayload(input: SettingsUpdateInput): { + data: Record; + ignoredSecretFields: string[]; +} { + const data: Record = {}; + 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; + + 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; + + 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; + } + + 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"); + + if (input.geminiApiKey !== undefined) ignoredSecretFields.push("geminiApiKey"); + if (input.geminiModel !== undefined) data.geminiModel = input.geminiModel || null; + + if (input.imageProvider !== undefined) data.imageProvider = input.imageProvider; + if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays; + if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps; + + return { data, ignoredSecretFields }; +} + +export function sanitizeSettingsAuditSnapshot(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value; + } + return result; +} + +export async function testRuntimeAiConnection(settings: { + aiProvider?: string | null; + azureOpenAiEndpoint?: string | null; + azureOpenAiDeployment?: string | null; + azureOpenAiApiKey?: string | null; + azureApiVersion?: string | null; +} | null | undefined): Promise<{ ok: boolean; error?: string }> { + 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 configuredSettings = settings as NonNullable; + const provider = configuredSettings.aiProvider ?? "openai"; + const apiKey = configuredSettings.azureOpenAiApiKey!; + + if (provider === "azure") { + const endpoint = configuredSettings.azureOpenAiEndpoint!.replace(/\/$/, ""); + const deployment = configuredSettings.azureOpenAiDeployment!; + const apiVersion = configuredSettings.azureApiVersion ?? "2025-01-01-preview"; + const url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`; + return performAiConnectionFetch(provider, url, { + "Content-Type": "application/json", + "api-key": apiKey, + }); + } + + const model = configuredSettings.azureOpenAiDeployment ?? "gpt-4o-mini"; + return performAiConnectionFetch( + provider, + "https://api.openai.com/v1/chat/completions", + { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + { model }, + ); +} + +async function performAiConnectionFetch( + provider: string, + url: string, + headers: Record, + bodyOverrides: Record = {}, +): Promise<{ ok: boolean; error?: string }> { + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + messages: [{ role: "user", content: "ping" }], + max_completion_tokens: 5, + ...bodyOverrides, + }), + }); + + const body = await response.text(); + if (response.ok) { + return { ok: true }; + } + + let message = body; + try { + const parsed = JSON.parse(body) as { error?: { message?: string } }; + if (parsed.error?.message) { + message = parsed.error.message; + } + } catch { + message = body; + } + + const raw = `HTTP ${response.status}: ${message}`; + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(raw) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(new Error(raw)) }; + } catch (error) { + logger.warn( + { provider, diagnostic: sanitizeDiagnosticError(error) }, + "AI connection test failed", + ); + return { ok: false, error: parseAiError(error) }; + } +} diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index e52cf56..a69b650 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -1,21 +1,16 @@ -import { z } from "zod"; import { adminProcedure, createTRPCRouter } from "../trpc.js"; -import { isAiConfigured, parseAiError, sanitizeDiagnosticError } from "../ai-client.js"; +import { isAiConfigured } 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", -]); +import { + buildSettingsUpdatePayload, + getDefaultScoreWeights, + sanitizeSettingsAuditSnapshot, + settingsUpdateInputSchema, + testRuntimeAiConnection, +} from "./settings-support.js"; export const settingsRouter = createTRPCRouter({ getSystemSettings: adminProcedure.query(async ({ ctx }) => { @@ -28,13 +23,7 @@ export const settingsRouter = createTRPCRouter({ (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, - }; + const defaultWeights = getDefaultScoreWeights(); return { aiProvider: settings?.aiProvider ?? "openai", @@ -78,113 +67,9 @@ export const settingsRouter = createTRPCRouter({ }), 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(), - }), - ) + .input(settingsUpdateInputSchema) .mutation(async ({ ctx, input }) => { - const data: Record = {}; - 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; + const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input); if (Object.keys(data).length === 0) { return { @@ -203,17 +88,8 @@ export const settingsRouter = createTRPCRouter({ update: data, }); - // Build sanitized snapshots — redact sensitive fields - const sanitize = (obj: Record): Record => { - const result: Record = {}; - 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) : undefined; - const sanitizedAfter = sanitize(data); + const sanitizedBefore = before ? sanitizeSettingsAuditSnapshot(before as unknown as Record) : undefined; + const sanitizedAfter = sanitizeSettingsAuditSnapshot(data); void createAuditEntry({ db: ctx.db, @@ -272,98 +148,7 @@ export const settingsRouter = createTRPCRouter({ 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; - - 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) }; - } + return testRuntimeAiConnection(settings); }), testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => {