66878f18f4
Infrastructure (Phase 1): - AuditLog schema: add source, entityName, summary fields + index - createAuditEntry() helper: auto-diff, auto-summary, fire-and-forget - auditLog query router: list, getByEntity, getTimeline, getActivitySummary Audit Coverage (Phase 2 — 14 routers, 50+ mutations): - vacation: create, approve, reject, cancel, batch ops (8 mutations) - user: create, updateRole, setPermissions, resetPermissions (5 mutations) - entitlement: set, bulkSet (3 mutations) - client: create, update, delete, batchUpdateSortOrder - org-unit: create, update, deactivate - country: create, update, createCity, updateCity, deleteCity - management-level: createGroup, updateGroup, createLevel, updateLevel, deleteLevel - settings: updateSystemSettings (sensitive fields sanitized), testSmtp - blueprint: create, update, updateRolePresets, delete, batchDelete, setGlobal - rate-card: create, update, deactivate, addLine, updateLine, deleteLine, replaceLines - calculation-rules: create, update, delete - effort-rule: create, update, delete - experience-multiplier: create, update, delete - utilization-category: create, update Admin UI (Phase 3): - /admin/activity-log page with global searchable timeline - Filters: entity type, action, user, date range, text search - Expandable before/after diff view per entry - Summary cards showing top entity types by change count - EntityHistory reusable component for entity detail pages - Sidebar nav link with clock icon AI Assistant (Phase 4): - query_change_history tool: "Who changed project X?" - get_entity_timeline tool: "What happened to resource Y?" Regression: 283 engine + 37 staffing tests pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
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";
|
|
import { createAuditEntry } from "../lib/audit.js";
|
|
|
|
/** Fields that must never appear in audit log values */
|
|
const SENSITIVE_FIELDS = new Set([
|
|
"azureOpenAiApiKey",
|
|
"smtpPassword",
|
|
"azureDalleApiKey",
|
|
"anonymizationSeed",
|
|
]);
|
|
|
|
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<string, unknown> = {};
|
|
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;
|
|
|
|
// Fetch current settings for before-snapshot
|
|
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
|
|
|
await ctx.db.systemSettings.upsert({
|
|
where: { id: "singleton" },
|
|
create: { id: "singleton", ...data },
|
|
update: data,
|
|
});
|
|
|
|
// Build sanitized snapshots — redact sensitive fields
|
|
const sanitize = (obj: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const sanitizedBefore = before ? sanitize(before as unknown as Record<string, unknown>) : undefined;
|
|
const sanitizedAfter = sanitize(data);
|
|
|
|
void createAuditEntry({
|
|
db: ctx.db,
|
|
entityType: "SystemSettings",
|
|
entityId: "singleton",
|
|
entityName: "System Settings",
|
|
action: before ? "UPDATE" : "CREATE",
|
|
userId: ctx.dbUser?.id,
|
|
...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}),
|
|
after: sanitizedAfter,
|
|
source: "ui",
|
|
});
|
|
|
|
return { ok: true };
|
|
}),
|
|
|
|
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<string, string>;
|
|
|
|
if (provider === "azure") {
|
|
const endpoint = settings!.azureOpenAiEndpoint!.replace(/\/$/, "");
|
|
const deployment = settings!.azureOpenAiDeployment!;
|
|
const apiVersion = settings!.azureApiVersion ?? "2025-01-01-preview";
|
|
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
|
|
headers = { "Content-Type": "application/json", "api-key": apiKey };
|
|
} else {
|
|
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
|
|
const model = settings!.azureOpenAiDeployment ?? "gpt-4o-mini";
|
|
url = "https://api.openai.com/v1/chat/completions";
|
|
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
// Override body to include model field for OpenAI
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [{ role: "user", content: "ping" }],
|
|
max_completion_tokens: 5,
|
|
}),
|
|
});
|
|
const body = await resp.text();
|
|
if (resp.ok) return { ok: true, 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 ({ 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;
|
|
}),
|
|
|
|
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) };
|
|
}),
|
|
});
|