L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- 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>
This commit is contained in:
@@ -5,6 +5,8 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
|
||||
|
||||
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 },
|
||||
@@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">):
|
||||
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" });
|
||||
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
|
||||
export async function getLotseSettings(ctx: ServiceCtx) {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const [enabled, s, budget] = await Promise.all([
|
||||
isLotseEnabled(ctx),
|
||||
isLotseSwitchedOn(ctx),
|
||||
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
|
||||
getTokenBudget(ctx),
|
||||
]);
|
||||
@@ -73,7 +86,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
|
||||
|
||||
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
|
||||
const before = {
|
||||
enabled: await isLotseEnabled(ctx),
|
||||
enabled: await isLotseSwitchedOn(ctx),
|
||||
addressForm: stored?.lotseAddressForm ?? null,
|
||||
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
|
||||
chatEnabled: stored?.lotseChatEnabled ?? true,
|
||||
|
||||
Reference in New Issue
Block a user