import { z } from "zod"; import { LOTSE_ADDRESS_FORMS, type LotseAddressForm } from "@/lib/lotse/content"; import type { ReportDraftInput } from "@/server/ai/providers"; import { AI_MODEL, isAiConfigured } from "@/server/ai/client"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { writeAuditLog } from "@/server/audit"; import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; import { getTenantPlan } from "@/server/plan"; import { isModuleInTier } from "@/lib/plans"; import { envMonthlyTokenLimit, getTokenBudget } from "./budget"; /** * Lotse settings per tenant (lane L9): on/off = module toggle `lotse` (TenantModule, missing row = on), * address form = `TenantSettings.lotseAddressForm` ("sie" | "du" | null = neutral). */ export const LOTSE_MODULE_KEY = "lotse"; /** Lotse usable for the tenant: module switched on AND included in the package tier (L17: Basis has no AI). */ export async function isLotseEnabled(ctx: Pick): Promise { if (!(await isModuleInPlan(ctx.tenantId, LOTSE_MODULE_KEY))) return false; return isLotseSwitchedOn(ctx); } /** Only the tenant switch (settings form shows it independently of the tier). */ async function isLotseSwitchedOn(ctx: Pick): Promise { const row = await ctx.db.tenantModule.findUnique({ where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, select: { enabled: true }, }); return !row || row.enabled; } async function isModuleInPlan(tenantId: string, moduleKey: string): Promise { return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey); } /** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */ export async function assertLotseEnabled(ctx: ServiceCtx): Promise { if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" }); } function toAddressForm(v: string | null | undefined): LotseAddressForm | null { return (LOTSE_ADDRESS_FORMS as readonly string[]).includes(v ?? "") ? (v as LotseAddressForm) : null; } /** Address form and language for prompts. */ export async function lotseVoice(ctx: Pick): Promise<{ addressForm: ReportDraftInput["addressForm"]; locale: ReportDraftInput["locale"] }> { const s = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, locale: true } }); return { addressForm: toAddressForm(s?.lotseAddressForm) ?? "neutral", locale: s?.locale === "en" ? "en" : "de" }; } export async function getLotseSettings(ctx: ServiceCtx) { assertCan(ctx, "tenant:manage"); const [enabled, s, budget] = await Promise.all([ isLotseSwitchedOn(ctx), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }), getTokenBudget(ctx), ]); const transcription = transcriptionConfig(); return { enabled, addressForm: toAddressForm(s?.lotseAddressForm), /** L16: „Lotse-Chat für Monteure" (default on) */ chatEnabled: s?.lotseChatEnabled ?? true, draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL }, transcription, budget: { ...budget, platformLimit: envMonthlyTokenLimit() }, }; } export const lotseSettingsSchema = z.object({ enabled: z.boolean(), addressForm: z.enum(["sie", "du", "neutral"]), /** L10b: tenant token budget per month; null = platform default, 0 = unlimited, undefined = unchanged */ monthlyTokenLimit: z.number().int().min(0).max(1_000_000_000).nullable().optional(), /** L16: Lotse chat for technicians; undefined = unchanged */ chatEnabled: z.boolean().optional(), }); export type LotseSettingsInput = z.input; export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInput) { assertCan(ctx, "tenant:manage"); const input = lotseSettingsSchema.parse(raw); const addressForm = input.addressForm === "neutral" ? null : input.addressForm; const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } }); const before = { enabled: await isLotseSwitchedOn(ctx), addressForm: stored?.lotseAddressForm ?? null, monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null, chatEnabled: stored?.lotseChatEnabled ?? true, }; const monthlyTokenLimit = input.monthlyTokenLimit === undefined ? before.monthlyTokenLimit : input.monthlyTokenLimit; const chatEnabled = input.chatEnabled === undefined ? before.chatEnabled : input.chatEnabled; await inTransaction(ctx, async (tx) => { await tx.db.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, update: { enabled: input.enabled }, create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled }, }); const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } }); const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit, lotseChatEnabled: chatEnabled }; if (existing) { await tx.db.tenantSettings.update({ where: { id: existing.id }, data }); } else { const tenant = await tx.db.tenant.findUnique({ where: { id: tx.tenantId }, select: { name: true } }); await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", ...data } }); } }); const after = { enabled: input.enabled, addressForm, monthlyTokenLimit, chatEnabled }; await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after }); return after; }