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 { 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; export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise { 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 { 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 }; }