188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
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";
|
|
|
|
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,
|
|
});
|
|
}),
|
|
|
|
updateSystemSettings: adminProcedure
|
|
.input(settingsUpdateInputSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input);
|
|
|
|
if (Object.keys(data).length === 0) {
|
|
return {
|
|
ok: true,
|
|
ignoredSecretFields,
|
|
secretStorageMode: "environment-only" as const,
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
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",
|
|
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) };
|
|
}),
|
|
});
|