- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
5.7 KiB
TypeScript
115 lines
5.7 KiB
TypeScript
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<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
|
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<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<ServiceCtx, "db">): 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<typeof lotseSettingsSchema>;
|
|
|
|
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;
|
|
}
|