refactor(api): extract settings procedures
This commit is contained in:
@@ -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
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TRPCContext, "db" | "dbUser">;
|
||||||
|
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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) };
|
||||||
|
}
|
||||||
@@ -1,187 +1,29 @@
|
|||||||
import { adminProcedure, createTRPCRouter } from "../trpc.js";
|
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 {
|
import {
|
||||||
buildSystemSettingsViewModel,
|
clearStoredRuntimeSecrets,
|
||||||
buildSettingsUpdatePayload,
|
getAiConfiguredStatus,
|
||||||
sanitizeSettingsAuditSnapshot,
|
getSystemSettingsView,
|
||||||
settingsUpdateInputSchema,
|
testSettingsAiConnection,
|
||||||
testRuntimeAiConnection,
|
testSettingsGeminiConnection,
|
||||||
} from "./settings-support.js";
|
testSettingsSmtpConnection,
|
||||||
|
updateSystemSettings,
|
||||||
|
} from "./settings-procedure-support.js";
|
||||||
|
import { settingsUpdateInputSchema } from "./settings-support.js";
|
||||||
|
|
||||||
export const settingsRouter = createTRPCRouter({
|
export const settingsRouter = createTRPCRouter({
|
||||||
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
|
getSystemSettings: adminProcedure.query(({ ctx }) => getSystemSettingsView(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,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateSystemSettings: adminProcedure
|
updateSystemSettings: adminProcedure
|
||||||
.input(settingsUpdateInputSchema)
|
.input(settingsUpdateInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(({ ctx, input }) => updateSystemSettings(ctx, input)),
|
||||||
const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input);
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
clearStoredRuntimeSecrets: adminProcedure.mutation(({ ctx }) => clearStoredRuntimeSecrets(ctx)),
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
ignoredSecretFields,
|
|
||||||
secretStorageMode: "environment-only" as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current settings for before-snapshot
|
testAiConnection: adminProcedure.mutation(({ ctx }) => testSettingsAiConnection(ctx)),
|
||||||
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
|
||||||
|
|
||||||
await ctx.db.systemSettings.upsert({
|
testSmtpConnection: adminProcedure.mutation(({ ctx }) => testSettingsSmtpConnection(ctx)),
|
||||||
where: { id: "singleton" },
|
|
||||||
create: { id: "singleton", ...data },
|
|
||||||
update: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sanitizedBefore = before ? sanitizeSettingsAuditSnapshot(before as unknown as Record<string, unknown>) : undefined;
|
testGeminiConnection: adminProcedure.mutation(({ ctx }) => testSettingsGeminiConnection(ctx)),
|
||||||
const sanitizedAfter = sanitizeSettingsAuditSnapshot(data);
|
|
||||||
|
|
||||||
void createAuditEntry({
|
getAiConfigured: adminProcedure.query(({ ctx }) => getAiConfiguredStatus(ctx)),
|
||||||
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) };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user