From 4586e94c95ac9f133796337686272fddf085a5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 19:46:50 +0200 Subject: [PATCH] refactor(api): extract settings procedures --- docs/domain-slices-backlog.md | 161 ++++++++++++++ .../settings-procedure-support.test.ts | 193 ++++++++++++++++ .../src/router/settings-procedure-support.ts | 206 ++++++++++++++++++ packages/api/src/router/settings.ts | 190 ++-------------- 4 files changed, 576 insertions(+), 174 deletions(-) create mode 100644 docs/domain-slices-backlog.md create mode 100644 packages/api/src/__tests__/settings-procedure-support.test.ts create mode 100644 packages/api/src/router/settings-procedure-support.ts diff --git a/docs/domain-slices-backlog.md b/docs/domain-slices-backlog.md new file mode 100644 index 0000000..99f39e4 --- /dev/null +++ b/docs/domain-slices-backlog.md @@ -0,0 +1,161 @@ +# Domain Slices Backlog + +Stand: 2026-03-31 + +Dieses Backlog gruppiert die offenen Arbeiten nach fachlichen/domänischen Blöcken statt nach Dateien. +Die Schätzungen sind Restaufwand auf Basis des aktuellen Worktrees und können sich nach Befund leicht ändern. + +Aktuell geschnittene Domänenslices: `7` +Grobe Gesamtdauer über alle noch offenen Slices: `28-45h` + +## Statuslegende + +- `in_progress`: wird aktiv bearbeitet +- `ready`: fachlich geschnitten, kann als nächster Block umgesetzt werden +- `blocked`: fachlich bekannt, aber aktuell von anderem Slice abhängig +- `done`: abgeschlossen und validiert + +## Offene Slices + +| # | Slice | Status | Restaufwand | Kernziel | +|---|---|---|---:|---| +| 1 | Timeline Interaction, Overlays, Hover, SSE-State | in_progress | 6-10h | Stabile Timeline ohne Render-/Overlay-/Drag-Artefakte, auch bei View-Wechseln und Live-Updates | +| 2 | Holiday/Vacation Correctness and Explainability | ready | 4-6h | Feiertage und Urlaube konsistent in Timeline, Chargeability, Forecasts, Assistant und UI-Erklärungen | +| 3 | Assistant Parity, Policy Auto-Exposure, Advanced Queries | ready | 5-8h | Assistant kennt und nutzt denselben Berechtigungs- und Datenraum wie der Nutzer, inkl. komplexer Queries | +| 4 | Reports, Templates/Blueprints, Export Completeness | ready | 4-6h | Report Builder mit speicherbaren Vorlagen und vollständigen Exportgrößen wie SAH und Bezugsfaktoren | +| 5 | Notifications, Tasks, Broadcasts, Reminder Reliability | ready | 3-5h | Robuste Inbox-/Broadcast-/Reminder-Flows ohne leere Fanout- oder Persistenzkanten | +| 6 | Dashboard, Widgets, Explainability, First-Load Stability | ready | 4-6h | Dashboard zuverlässig ladbar, Widgets kompakt, nachvollziehbar und per Detail-Toggle kontrollierbar | +| 7 | DB Safety, Env Loading, Migration/Seed Discipline, Naming Cleanup | ready | 2-4h | Keine Raw-Prisma/Env-Fallen, kein versehentliches Seeding, keine operativen `planarchy`-Reste | + +## Umsetzungshinweise + +- Slice 1 ist der größte technische Risikoblock und beeinflusst direkt wahrnehmbare UX. +- Slice 2 und 3 hängen fachlich teilweise zusammen, können nach Slice 1 parallelisiert werden. +- Slice 4 profitiert von Slice 2, weil Feiertags-/Urlaubslogik als Export- und Reportgröße sichtbar werden muss. +- Slice 7 bleibt als Guardrail-Block parallel im Hintergrund wichtig, sollte aber keine Produktarbeit blockieren. + +## Abhaklog + +- [x] Assistant-Testlandschaft entlang echter Domänengrenzen weiter zerlegt und vollständig grün validiert +- [ ] Slice 1: Timeline Interaction, Overlays, Hover, SSE-State + Fortschritt: + Live `cellWidthRef` statt stale Snapshot im Drag-Pfad verdrahtet. + Projekt-Drag zentralisiert finalisiert, sodass MouseUp/Canvas-Up nicht mehr parallel mutieren. + `document`-Listener für Projekt-Drag, Allocation-Drag und Multi-Select werden beim Unmount sauber entfernt. + Timeline-Popovers schließen bei Scroll/Resize nicht mehr sofort, sondern bleiben stabil sichtbar. +- [ ] Slice 2: Holiday/Vacation Correctness and Explainability +- [ ] Slice 3: Assistant Parity, Policy Auto-Exposure, Advanced Queries +- [ ] Slice 4: Reports, Templates/Blueprints, Export Completeness +- [ ] Slice 5: Notifications, Tasks, Broadcasts, Reminder Reliability +- [ ] Slice 6: Dashboard, Widgets, Explainability, First-Load Stability +- [ ] Slice 7: DB Safety, Env Loading, Migration/Seed Discipline, Naming Cleanup + +## API Router Slice Inventory + +Diese Liste ist die vollständige Arbeitsinventur für den aktuellen Architektur-Track +`packages/api/src/router`. +Ziel ist nicht eine inhaltliche Neuschreibung, sondern dünne Top-Level-Router: +Zod/Input, Permission-Wiring und Orchestrierung wandern in gezielte `*-procedure-support.ts` +oder benachbarte Support-Module, Verhalten und öffentliche Contracts bleiben stabil. + +### Status + +- `done`: bereits sauber extrahiert oder als dünner Kompositionsrouter erreicht +- `ready_now`: sinnvoller nächster Slice ohne bekannte Blocker +- `later`: fachlich sinnvoll, aber größer oder stärker querliegend +- `monitor`: aktuell dünn genug; nur anfassen, wenn dort neue Logik wächst +- `excluded_for_now`: bewusst aus dem aktuellen Batch ausgenommen + +### Vollständige Router-Liste + +| Router | Status | Priorität | Sinnvolle Slices | +|---|---|---:|---| +| `allocation.ts` | done | - | Aggregator bereits dünn; nur Support-Module weiter pflegen | +| `assistant.ts` | later | 3 | Chat loop, confirmation flow, insight generation, response shaping | +| `audit-log.ts` | monitor | 4 | Optional: list/detail reads und filter-building trennen, falls Auth/Wiring wächst | +| `blueprint.ts` | ready_now | 2 | summary/list reads, identifier reads, CRUD, role-preset mutation, global-field procedures | +| `calculation-rules.ts` | monitor | 4 | Aktuell klein; nur bei zusätzlicher Write-/Audit-Logik extrahieren | +| `chargeability-report.ts` | excluded_for_now | - | Bewusst zurückgestellt; erst nach Freigabe des geblockten Bereichs | +| `client.ts` | ready_now | 2 | list/tree reads, identifier reads, CRUD/deactivate, sort-order batch mutation | +| `comment.ts` | done | - | Bereits in `comment-procedure-support.ts` ausgelagert | +| `computation-graph.ts` | monitor | 4 | Bereits Kompositionsrouter; nur bei zusätzlicher Orchestrierung weiter schneiden | +| `country.ts` | ready_now | 2 | list/identifier reads, country CRUD, metro-city CRUD | +| `dashboard.ts` | excluded_for_now | - | Bewusst zurückgestellt; hoher Querschnitt und paralleler Scope | +| `dispo.ts` | ready_now | 2 | workbook input schemas, staging/validation, staged-read procedures, resolve/commit/cancel mutations | +| `effort-rule.ts` | ready_now | 1 | list/detail reads, CRUD/default toggle, preview, apply-rules mutation | +| `entitlement.ts` | excluded_for_now | - | Bewusst zurückgestellt; größerer Fachblock | +| `estimate.ts` | later | 3 | commercial reads, demand line writes, phasing, version workflow, aggregate read models | +| `experience-multiplier.ts` | ready_now | 1 | list/detail reads, CRUD/default toggle, preview, apply-rules mutation | +| `holiday-calendar.ts` | ready_now | 2 | calendar CRUD, entry CRUD; catalog/resolution reads sind schon separat | +| `import-export.ts` | ready_now | 3 | export reads, import preview/validation, file-bound orchestration | +| `insights.ts` | ready_now | 3 | anomaly/summary reads, cached narrative read, AI narrative mutation | +| `management-level.ts` | ready_now | 1 | group reads/writes, level writes/delete, audit helper extraction | +| `notification.ts` | excluded_for_now | - | Bewusst zurückgestellt; zu groß für den aktuellen Batch | +| `org-unit.ts` | ready_now | 3 | list/tree reads, identifier reads, CRUD/deactivate | +| `project.ts` | monitor | 4 | Bereits stark komponiert; optional nur `list` und `getById` separat ziehen | +| `rate-card.ts` | ready_now | 1 | list/detail/match reads, card CRUD, line CRUD, replace-lines/batch operations | +| `report.ts` | done | - | Bereits in `report-template-procedure-support.ts` ausgelagert | +| `resource.ts` | monitor | 4 | Top-Level dünn; tieferliegende Shared-Read-Module sind separat zu betrachten | +| `role.ts` | done | - | Bereits in `role-procedure-support.ts` ausgelagert | +| `scenario.ts` | done | - | Bereits in `scenario-procedure-support.ts` ausgelagert | +| `settings.ts` | done | - | Bereits in `settings-procedure-support.ts` ausgelagert | +| `staffing.ts` | done | - | Aggregator bereits dünn | +| `system-role-config.ts` | monitor | 4 | Klein genug; nur bei wachsender Audit-/Validation-Logik schneiden | +| `timeline.ts` | done | - | Bereits auf dünnen Aggregator umgestellt | +| `user.ts` | excluded_for_now | - | Bewusst zurückgestellt; hoher Querschnitt und paralleler Scope | +| `utilization-category.ts` | monitor | 4 | Klein genug; nur bei zusätzlicher Komplexität schneiden | +| `vacation.ts` | done | - | Bereits auf dünnen Aggregator umgestellt | +| `webhook.ts` | monitor | 4 | Noch tragbar; optional später read/write separation | + +### Empfohlene Reihenfolge + +1. `rate-card.ts` +2. `effort-rule.ts` +3. `experience-multiplier.ts` +4. `management-level.ts` +5. `blueprint.ts` +6. `client.ts` +7. `country.ts` +8. `holiday-calendar.ts` +9. `dispo.ts` +10. `insights.ts` +11. `import-export.ts` +12. `org-unit.ts` + +### Phase 2: Tieferliegende Hotspots Hinter Bereits Dünnen Routern + +Diese Slices betreffen nicht mehr primär den Top-Level-Router, sondern große Read-/Procedure-Module +im selben Architektur-Track. Sie sind sinnvoll, sobald der aktuelle Router-Arbeitsvorrat abgearbeitet ist. + +| Slice | Priorität | Betroffene Module | Ziel | +|---|---:|---|---| +| Allocation Mutation Decomposition | 1 | `allocation-assignment-procedures.ts` | Assignment-Transaktionen, Audit, Events, Webhooks und Budget-Folgelogik weiter trennen | +| Vacation Read Separation | 1 | `vacation-read.ts`, `vacation-management-procedures.ts` | Ownership, Anonymisierung, Holiday-Preview und Query-Varianten sauberer schneiden | +| Estimate Write Router Slimming | 1 | `estimate.ts` plus benachbarte Estimate-Module | Write-Orchestrierung, version workflow und error translation aus dem Router ziehen | +| Staffing Read Pipeline Split | 1 | `staffing-suggestions-read.ts`, `staffing-capacity-read.ts`, `staffing-best-project-resource.ts` | gemeinsame Load-/Ranking-/Response-Pipeline extrahieren | +| Report Engine by Execution Mode | 2 | `report-query-engine.ts`, `report-query-config.ts` | generische Reports, `resource_month`-Sonderfall und Exportpfade klar trennen | +| Computation Graph Read Model Split | 2 | `computation-graph-resource.ts`, `computation-graph-project.ts` | Snapshot-Building, Forecasting und Graph-Assembly entkoppeln | + +### Explizit Ausgeschlossen Im Aktuellen Batch + +- `packages/api/src/router/assistant-tools.ts` +- `packages/api/src/router/assistant-tools/*` +- `packages/api/src/router/chargeability-report.ts` +- `packages/api/src/router/dashboard.ts` +- `packages/api/src/router/entitlement.ts` +- `packages/api/src/router/notification.ts` +- `packages/api/src/router/resource-read-shared.ts` +- `packages/api/src/router/resource-summary-read.ts` +- `packages/api/src/router/user.ts` +- `packages/api/src/__tests__/timeline-router.test.ts` +- `packages/api/src/__tests__/vacation-router.test.ts` + +### Bereits Erledigte Router-Slices In Dieser Serie + +- `timeline`: read schemas, holiday/project/allocation/shift support extrahiert +- `vacation`: create procedures extrahiert +- `comment`: comment procedures extrahiert +- `scenario`: scenario procedures extrahiert +- `report`: report template procedures extrahiert +- `role`: role write procedures extrahiert +- `settings`: settings procedures extrahiert diff --git a/packages/api/src/__tests__/settings-procedure-support.test.ts b/packages/api/src/__tests__/settings-procedure-support.test.ts new file mode 100644 index 0000000..8b9a4a3 --- /dev/null +++ b/packages/api/src/__tests__/settings-procedure-support.test.ts @@ -0,0 +1,193 @@ +import { SystemRole } from "@capakraken/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + createAuditEntry, + testSmtpConnection, + generateGeminiImage, + parseGeminiError, +} = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), + testSmtpConnection: vi.fn(), + generateGeminiImage: vi.fn(), + parseGeminiError: vi.fn((error: unknown) => `parsed:${String(error)}`), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +vi.mock("../lib/email.js", () => ({ + testSmtpConnection, +})); + +vi.mock("../gemini-client.js", () => ({ + generateGeminiImage, + parseGeminiError, +})); + +import { + clearStoredRuntimeSecrets, + getAiConfiguredStatus, + getSystemSettingsView, + testSettingsGeminiConnection, + testSettingsSmtpConnection, + updateSystemSettings, +} from "../router/settings-procedure-support.js"; + +function createAdminContext(db: Record) { + return { + db: db as never, + dbUser: { + id: "admin_1", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }; +} + +describe("settings procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("builds the system settings view model from db and runtime state", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + vi.stubEnv("SMTP_PASSWORD", "env-smtp-password"); + + const result = await getSystemSettingsView(createAdminContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiApiKey: null, + smtpPassword: null, + }), + }, + })); + + expect(result.hasApiKey).toBe(true); + expect(result.hasSmtpPassword).toBe(true); + expect(result.aiProvider).toBe("openai"); + expect(result.defaultSummaryPrompt).toBeTypeOf("string"); + }); + + it("updates settings without persisting incoming secret fields and writes an audit entry", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "azure", + azureOpenAiApiKey: "secret", + }); + const upsert = vi.fn().mockResolvedValue({}); + + const result = await updateSystemSettings(createAdminContext({ + systemSettings: { + findUnique, + upsert, + }, + }), { + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: "should-be-ignored", + smtpPassword: "also-ignored", + }); + + expect(result).toEqual({ + ok: true, + ignoredSecretFields: ["azureOpenAiApiKey", "smtpPassword"], + secretStorageMode: "environment-only", + }); + expect(upsert).toHaveBeenCalledWith({ + where: { id: "singleton" }, + create: { + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }, + update: { + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }, + }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "SystemSettings", + action: "UPDATE", + after: expect.objectContaining({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + }), + })); + }); + + it("clears only legacy runtime secrets that are still stored", async () => { + const update = vi.fn().mockResolvedValue({}); + + const result = await clearStoredRuntimeSecrets(createAdminContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + azureOpenAiApiKey: "db-key", + azureDalleApiKey: null, + geminiApiKey: "db-gemini", + smtpPassword: null, + anonymizationSeed: "seed", + }), + update, + }, + })); + + expect(result).toEqual({ + ok: true, + clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "anonymizationSeed"], + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "singleton" }, + data: { + azureOpenAiApiKey: null, + geminiApiKey: null, + anonymizationSeed: null, + }, + }); + }); + + it("tests smtp and gemini connections with audited outcomes", async () => { + testSmtpConnection.mockResolvedValue({ ok: true }); + generateGeminiImage.mockResolvedValue("data:image/png;base64,abc123"); + + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + geminiApiKey: "gem-key", + geminiModel: "gem-model", + }), + }, + }; + const ctx = createAdminContext(db); + + expect(await testSettingsSmtpConnection(ctx)).toEqual({ ok: true }); + expect(await testSettingsGeminiConnection(ctx)).toEqual({ + ok: true, + model: "gem-model", + preview: "data:image/png;base64,abc123...", + }); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + }); + + it("reads the ai configured status from runtime-resolved settings", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + + const result = await getAiConfiguredStatus(createAdminContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: null, + }), + }, + })); + + expect(result).toEqual({ configured: true }); + }); +}); diff --git a/packages/api/src/router/settings-procedure-support.ts b/packages/api/src/router/settings-procedure-support.ts new file mode 100644 index 0000000..005b2ab --- /dev/null +++ b/packages/api/src/router/settings-procedure-support.ts @@ -0,0 +1,206 @@ +import { isAiConfigured } from "../ai-client.js"; +import { createAuditEntry } from "../lib/audit.js"; +import { testSmtpConnection } from "../lib/email.js"; +import { + getRuntimeSecretStatuses, + RUNTIME_SECRET_FIELDS, + resolveSystemSettingsRuntime, +} from "../lib/system-settings-runtime.js"; +import type { TRPCContext } from "../trpc.js"; +import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; +import { + buildSettingsUpdatePayload, + buildSystemSettingsViewModel, + sanitizeSettingsAuditSnapshot, + type SettingsUpdateInput, + testRuntimeAiConnection, +} from "./settings-support.js"; + +type SettingsProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export async function getSystemSettingsView( + ctx: SettingsProcedureContext, +) { + const settings = await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + }); + const runtimeSettings = resolveSystemSettingsRuntime(settings); + const runtimeSecrets = getRuntimeSecretStatuses(settings); + return buildSystemSettingsViewModel({ + settings, + runtimeSettings, + runtimeSecrets, + defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, + }); +} + +export async function updateSystemSettings( + ctx: SettingsProcedureContext, + input: SettingsUpdateInput, +) { + const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input); + + if (Object.keys(data).length === 0) { + return { + ok: true, + ignoredSecretFields, + secretStorageMode: "environment-only" as const, + }; + } + + const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); + + await ctx.db.systemSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...data }, + update: data, + }); + + const sanitizedBefore = before + ? sanitizeSettingsAuditSnapshot(before as unknown as Record) + : undefined; + const sanitizedAfter = sanitizeSettingsAuditSnapshot(data); + + void createAuditEntry({ + db: ctx.db, + entityType: "SystemSettings", + entityId: "singleton", + entityName: "System Settings", + action: before ? "UPDATE" : "CREATE", + ...withAuditUser(ctx.dbUser?.id), + ...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}), + after: sanitizedAfter, + source: "ui", + }); + + return { + ok: true, + ignoredSecretFields, + secretStorageMode: "environment-only" as const, + }; +} + +export async function clearStoredRuntimeSecrets( + ctx: SettingsProcedureContext, +) { + 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", + ...withAuditUser(ctx.dbUser?.id), + after: { clearedFields }, + source: "ui", + summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`, + }); + + return { ok: true, clearedFields }; +} + +export async function testSettingsAiConnection( + ctx: SettingsProcedureContext, +) { + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + })); + return testRuntimeAiConnection(settings); +} + +export async function testSettingsSmtpConnection( + ctx: SettingsProcedureContext, +) { + const result = await testSmtpConnection(); + + void createAuditEntry({ + db: ctx.db, + entityType: "SystemSettings", + entityId: "singleton", + entityName: "SMTP Connection Test", + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + after: { testResult: result.ok ? "success" : "failed" }, + source: "ui", + summary: result.ok ? "SMTP connection test succeeded" : "SMTP connection test failed", + }); + + return result; +} + +export async function testSettingsGeminiConnection( + ctx: SettingsProcedureContext, +) { + 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"; + 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", + ...withAuditUser(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) }; + } +} + +export async function getAiConfiguredStatus( + ctx: SettingsProcedureContext, +) { + const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({ + where: { id: "singleton" }, + select: { + aiProvider: true, + azureOpenAiEndpoint: true, + azureOpenAiDeployment: true, + azureOpenAiApiKey: true, + }, + })); + return { configured: isAiConfigured(settings) }; +} diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index 0d993f3..1e5b22f 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -1,187 +1,29 @@ import { adminProcedure, createTRPCRouter } from "../trpc.js"; -import { isAiConfigured } from "../ai-client.js"; -import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; -import { testSmtpConnection } from "../lib/email.js"; -import { createAuditEntry } from "../lib/audit.js"; -import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; import { - buildSystemSettingsViewModel, - buildSettingsUpdatePayload, - sanitizeSettingsAuditSnapshot, - settingsUpdateInputSchema, - testRuntimeAiConnection, -} from "./settings-support.js"; + clearStoredRuntimeSecrets, + getAiConfiguredStatus, + getSystemSettingsView, + testSettingsAiConnection, + testSettingsGeminiConnection, + testSettingsSmtpConnection, + updateSystemSettings, +} from "./settings-procedure-support.js"; +import { settingsUpdateInputSchema } from "./settings-support.js"; 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); - return buildSystemSettingsViewModel({ - settings, - runtimeSettings, - runtimeSecrets, - defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, - }); - }), + getSystemSettings: adminProcedure.query(({ ctx }) => getSystemSettingsView(ctx)), updateSystemSettings: adminProcedure .input(settingsUpdateInputSchema) - .mutation(async ({ ctx, input }) => { - const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input); + .mutation(({ ctx, input }) => updateSystemSettings(ctx, input)), - if (Object.keys(data).length === 0) { - return { - ok: true, - ignoredSecretFields, - secretStorageMode: "environment-only" as const, - }; - } + clearStoredRuntimeSecrets: adminProcedure.mutation(({ ctx }) => clearStoredRuntimeSecrets(ctx)), - // Fetch current settings for before-snapshot - const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); + testAiConnection: adminProcedure.mutation(({ ctx }) => testSettingsAiConnection(ctx)), - await ctx.db.systemSettings.upsert({ - where: { id: "singleton" }, - create: { id: "singleton", ...data }, - update: data, - }); + testSmtpConnection: adminProcedure.mutation(({ ctx }) => testSettingsSmtpConnection(ctx)), - const sanitizedBefore = before ? sanitizeSettingsAuditSnapshot(before as unknown as Record) : undefined; - const sanitizedAfter = sanitizeSettingsAuditSnapshot(data); + testGeminiConnection: adminProcedure.mutation(({ ctx }) => testSettingsGeminiConnection(ctx)), - 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" }, - })); - return testRuntimeAiConnection(settings); - }), - - 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) }; - }), + getAiConfigured: adminProcedure.query(({ ctx }) => getAiConfiguredStatus(ctx)), });