refactor(api): extract settings procedures
This commit is contained in:
@@ -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 { 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<string, unknown>) : 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)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user