import { z } from "zod"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js"; import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared"; import { testSmtpConnection } from "../lib/email.js"; export const settingsRouter = createTRPCRouter({ getSystemSettings: adminProcedure.query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); 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: !!settings?.azureOpenAiApiKey, 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: !!settings?.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: !!settings?.azureDalleApiKey, // 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(), // 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 = {}; 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) data.azureOpenAiApiKey = input.azureOpenAiApiKey || null; 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) data.smtpPassword = input.smtpPassword || null; 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) { data.anonymizationSeed = input.anonymizationSeed || null; data.anonymizationAliases = null; } 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) data.azureDalleApiKey = input.azureDalleApiKey || null; // Vacation if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays; // Timeline if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps; await ctx.db.systemSettings.upsert({ where: { id: "singleton" }, create: { id: "singleton", ...data }, update: data, }); return { ok: true }; }), testAiConnection: adminProcedure.mutation(async ({ ctx }) => { const settings = 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, raw: null }; 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}`; return { ok: false, error: parseAiError(new Error(raw)), raw }; } catch (err) { const raw = err instanceof Error ? err.message : String(err); return { ok: false, error: parseAiError(err), raw }; } } 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, raw: null }; } 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}`; return { ok: false, error: parseAiError(new Error(raw)), raw }; } catch (err) { const raw = err instanceof Error ? err.message : String(err); return { ok: false, error: parseAiError(err), raw }; } }), testSmtpConnection: adminProcedure.mutation(async () => { return testSmtpConnection(); }), getAiConfigured: protectedProcedure.query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, select: { aiProvider: true, azureOpenAiEndpoint: true, azureOpenAiDeployment: true, azureOpenAiApiKey: true, }, }); return { configured: isAiConfigured(settings) }; }), });