- 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>
67 lines
4.0 KiB
TypeScript
67 lines
4.0 KiB
TypeScript
import { z } from "zod";
|
|
import { dbForTenant, prisma } from "@/server/db";
|
|
import { writeAuditLog } from "@/server/audit";
|
|
import { ServiceError } from "@/server/services/context";
|
|
import { TENANT_TIERS } from "@/lib/plans";
|
|
import { getChatUsageForOperator, type ChatUsage } from "@/server/services/lotse/chat/usage";
|
|
import { countAssignedSeats } from "@/server/services/lotse/chat/seats";
|
|
import { toPlanInfo, PLAN_SELECT, type TenantPlanInfo } from "@/server/plan";
|
|
|
|
/**
|
|
* L17 Pakete — operator (platform full admin) operations: package tier, number of purchased Lotse chat
|
|
* seats and the hard limit. Tenant administrators can NOT change these (they have no PlatformAdmin row →
|
|
* `forbidden`); the adapter additionally requires the platform session (src/server/actions/plans-platform.ts).
|
|
* Audit: scope platform, attached to the tenant (before/after), same pattern as the L15 trial actions.
|
|
* Switching PROFI → BASIS keeps all data (seats, billing records, chats); only access ends.
|
|
*/
|
|
|
|
export type PlatformActor = { platformAdminId: string };
|
|
|
|
async function assertPlatformFullAdmin(actor: PlatformActor): Promise<void> {
|
|
const admin = actor.platformAdminId
|
|
? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
|
|
: null;
|
|
if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
|
|
}
|
|
|
|
export const MAX_LOTSE_CHAT_SEATS = 1000;
|
|
|
|
export const tenantPlanSchema = z.object({
|
|
tier: z.enum(TENANT_TIERS),
|
|
lotseChatSeats: z.coerce.number().int().min(0).max(MAX_LOTSE_CHAT_SEATS),
|
|
lotseChatHardLimit: z.boolean(),
|
|
});
|
|
export type TenantPlanInput = z.input<typeof tenantPlanSchema>;
|
|
|
|
export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise<TenantPlanInfo> {
|
|
await assertPlatformFullAdmin(actor);
|
|
const parsed = tenantPlanSchema.safeParse(raw);
|
|
if (!parsed.success) throw new ServiceError("invalid", "invalid_input", parsed.error.issues.map((i) => ({ path: i.path.join("."), code: i.code })));
|
|
const input = parsed.data;
|
|
const before = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, status: true, ...PLAN_SELECT } });
|
|
if (!before) throw new ServiceError("not_found", "tenant not found");
|
|
if (before.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_archived");
|
|
const after = await prisma.tenant.update({ where: { id: tenantId }, data: input, select: PLAN_SELECT });
|
|
const snap = (r: { tier: string; lotseChatSeats: number; lotseChatHardLimit: boolean }) => ({ tier: r.tier, lotseChatSeats: r.lotseChatSeats, lotseChatHardLimit: r.lotseChatHardLimit });
|
|
await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity: "tenant_plan", entityId: tenantId, before: snap(before), after: snap(after) });
|
|
return toPlanInfo(after);
|
|
}
|
|
|
|
export type TenantPlanOverview = {
|
|
plan: TenantPlanInfo;
|
|
seatsAssigned: number;
|
|
current: ChatUsage;
|
|
previous: ChatUsage;
|
|
};
|
|
|
|
/** Operator view for the tenant detail page: plan, seats assigned/purchased, chats of the current and previous month. */
|
|
export async function getTenantPlanOverview(actor: PlatformActor, tenantId: string, now: Date = new Date()): Promise<TenantPlanOverview> {
|
|
const admin = actor.platformAdminId ? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true } }) : null;
|
|
if (!admin || admin.status !== "ACTIVE") throw new ServiceError("forbidden", "platform_admin_required");
|
|
const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT });
|
|
if (!row) throw new ServiceError("not_found", "tenant not found");
|
|
const plan = toPlanInfo(row);
|
|
const [{ current, previous }, seats] = await Promise.all([getChatUsageForOperator(tenantId, now), countAssignedSeats({ db: dbForTenant(tenantId), tenantId }, plan.lotseChatSeats)]);
|
|
return { plan, seatsAssigned: seats.assigned, current, previous };
|
|
}
|