// L17 Pakete — Lotse-Chat pro Nutzer: Platzvergabe bis zur Grenze (darüber invalid), Chat ohne Platz → // blocked no_seat, mit Platz → ok, Testphase ohne Platzprüfung, Kontingentzählung über die Monatsgrenze // (Zeitzone), Mehrverbrauch weiterzählen, hartes Limit → blocked quota_exhausted, Feldrollen vergeben // keine Plätze, Mandantentrennung (B sieht/ändert keine Plätze/Zählung von A), Betreiber-Übersicht. // Lauf: npx tsx scripts/test-pakete-seats.ts (auch mit RLS_ENFORCED=true) import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import { FakeLotseChatProvider } from "../src/server/ai/lotse/chat-fake"; import { assertLotseChatUsable, canUseLotseChat, isChatQuotaBlocked } from "../src/server/services/lotse/chat/access"; import { getChatView } from "../src/server/services/lotse/chat/conversations"; import { sendLotseMessage } from "../src/server/services/lotse/chat/engine"; import { getSeatOverview, listEligibleUsers, setChatSeat } from "../src/server/services/lotse/chat/seats"; import { getChatUsage, getChatUsageForAdmin, monthBounds, monthKeyOf } from "../src/server/services/lotse/chat/usage"; import { getTenantPlanOverview, updateTenantPlan } from "../src/server/services/plans/platform"; import { ServiceError } from "../src/server/services/context"; import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-pak-seats-a"; const SLUG_B = "zz-pak-seats-b"; const PLATFORM_EMAIL = "platform-seats@zz-pakete.test"; async function reasonOf(p: Promise): Promise { try { await p; return "ok"; } catch (err) { if (err instanceof ServiceError) return `${err.code}/${(err.details as { reason?: string } | undefined)?.reason ?? err.message}`; return `error:${(err as Error).message}`; } } async function extraCleanup(tenantIds: string[]) { const w = { where: { tenantId: { in: tenantIds } } }; await prisma.lotseChatSeat.deleteMany(w); await prisma.lotseActionProposal.deleteMany(w); await prisma.lotseMessage.deleteMany(w); await prisma.lotseConversation.deleteMany(w); await prisma.platformAdmin.deleteMany({ where: { email: PLATFORM_EMAIL } }); } runSuite("L17 Pakete – Lotse-Chat-Plätze & Kontingent", [SLUG_A, SLUG_B], async () => { await extraCleanup([]); const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); try { const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } }); const actor = { platformAdminId: platform.id }; await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false }); await updateTenantPlan(actor, B.tenantId, { tier: "PROFI", lotseChatSeats: 5, lotseChatHardLimit: false }); section("Berechtigte Nutzer (Feldrolle: lotse:use + field:execute)"); const eligible = (await listEligibleUsers(A.ctx.admin)).map((u) => u.id); const u = A.users; ok([u.tech.id, u.tech2.id, u.outsider.id, u.lead.id, u.lead2.id].every((id) => eligible.includes(id)), "Monteure und Teamleiter berechtigt"); ok(!eligible.includes(u.backoffice.id), "Backoffice ohne field:execute nicht berechtigt"); ok(eligible.includes(u.admin.id), "Mandantenadministrator hat beide Rechte → berechtigt (Regel = Rechte, nicht Rollenname)"); section("Chat ohne Platz → blocked no_seat"); ok(!(await canUseLotseChat(A.ctx.tech)), "canUseLotseChat ohne Platz → false (kein Navigationseintrag)"); ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/no_seat", "assertLotseChatUsable → blocked no_seat"); ok((await reasonOf(getChatView(A.ctx.tech))) === "blocked/no_seat", "direkter Aufruf /m/lotse (getChatView) → no_seat"); const provider = () => new FakeLotseChatProvider([{ text: "Alles klar." }]); ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Hallo" }, { provider: provider() }))) === "blocked/no_seat", "Senden ohne Platz → no_seat"); ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === 0, "ohne Platz wird nichts gespeichert"); section("Platzvergabe bis zur Grenze"); const r1 = await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true }); ok(r1.changed, "Platz 1 an Monteur vergeben"); await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }); const overview = await getSeatOverview(A.ctx.admin); ok(overview.purchased === 2 && overview.assigned === 2 && !overview.overbooked, "2 von 2 Plätzen vergeben"); ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }))) === "invalid/no_seats_left", "dritter Platz → invalid no_seats_left"); ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Grenze serverseitig gehalten"); await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false }); const parallel = await Promise.allSettled([u.tech2.id, u.outsider.id].map((userId) => setChatSeat(A.ctx.admin, { userId, assigned: true }))); ok(parallel.filter((p) => p.status === "fulfilled").length === 1 && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "ein freier Platz, zwei parallele Vergaben → genau eine gelingt"); for (const userId of [u.tech2.id, u.outsider.id]) await setChatSeat(A.ctx.admin, { userId, assigned: false }); await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }); ok(!(await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true })).changed, "erneutes Vergeben idempotent"); ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: false }))) === "ok", "Entzug ohne Platz idempotent"); const auditCreate = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.tech.id, action: "create" } }); ok(!!auditCreate && (auditCreate.after as { userId?: string })?.userId === u.tech.id && auditCreate.actorId === u.admin.id, "Audit bei Vergabe (after)"); section("Chat mit Platz → ok"); ok(await canUseLotseChat(A.ctx.tech), "canUseLotseChat mit Platz → true"); ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Chat-Ansicht mit Platz"); const view = await sendLotseMessage(A.ctx.tech, { text: "Wie ist der Stand?" }, { provider: provider() }); ok(view.messages.some((m) => m.role === "user"), "Nachricht mit Platz gesendet"); ok((await canUseLotseChat(A.ctx.lead)) && !(await canUseLotseChat(A.ctx.tech2)), "Teamleiter mit Platz ja, Monteur ohne Platz nein"); section("Entzug + Überbelegung"); await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false }); ok((await reasonOf(assertLotseChatUsable(A.ctx.lead))) === "blocked/no_seat", "nach Entzug → no_seat"); const auditDelete = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.lead.id, action: "delete" } }); ok(!!auditDelete && (auditDelete.before as { userId?: string })?.userId === u.lead.id, "Audit bei Entzug (before)"); await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }); await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 1, lotseChatHardLimit: false }); const over = await getSeatOverview(A.ctx.admin); ok(over.overbooked && over.users.find((x) => x.id === u.tech.id)?.valid === true && over.users.find((x) => x.id === u.tech2.id)?.valid === false, "Betreiber senkt auf 1: nur der zuerst vergebene Platz gilt"); ok((await reasonOf(assertLotseChatUsable(A.ctx.tech2))) === "blocked/no_seat", "überzähliger Platz → no_seat"); await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false }); ok(await canUseLotseChat(A.ctx.tech2), "wieder 2 Plätze → beide nutzbar"); section("Feldrollen und Backoffice vergeben keine Plätze"); for (const p of ["tech", "lead", "backoffice"] as const) { await expectCode(() => setChatSeat(A.ctx[p], { userId: u.outsider.id, assigned: true }), "forbidden", `${p} → forbidden`); await expectCode(() => getSeatOverview(A.ctx[p]), "forbidden", `${p} sieht die Platzverwaltung nicht`); await expectCode(() => getChatUsageForAdmin(A.ctx[p]), "forbidden", `${p} sieht den Verbrauch nicht`); } ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: true }))) === "invalid/not_eligible", "Platz an Nutzer ohne Feldrolle → invalid not_eligible"); await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "DEACTIVATED" } }); await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: false }); ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.outsider.id, assigned: true }))) === "invalid/not_eligible", "deaktivierter Nutzer → invalid not_eligible"); await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "ACTIVE" } }); await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }); section("Mandantentrennung"); ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: true }))) === "invalid/not_eligible", "B vergibt keinen Platz an Nutzer aus A"); ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: false }))) === "ok" && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId, userId: u.tech.id } })) === 1, "B kann den Platz von A nicht entziehen"); const bOverview = await getSeatOverview(B.ctx.admin); ok(!bOverview.users.some((x) => [u.tech.id, u.tech2.id].includes(x.id)) && bOverview.assigned === 0, "B sieht keine Plätze/Nutzer von A"); ok((await dbForTenant(B.tenantId).lotseChatSeat.findMany()).length === 0, "Tenant-Client von B findet keine Platz-Zeilen von A"); ok((await getChatUsageForAdmin(B.ctx.admin)).used === 0, "B zählt keine Chats von A"); ok((await reasonOf(assertLotseChatUsable(B.ctx.tech))) === "blocked/no_seat", "B-Monteur hat keinen Platz (Plätze gelten je Mandant)"); section("Kontingentzählung über die Monatsgrenze (Zeitzone Europe/Berlin)"); const tz = "Europe/Berlin"; ok(monthKeyOf(new Date("2026-02-28T23:30:00Z"), tz) === "2026-03" && monthKeyOf(new Date("2026-02-28T22:30:00Z"), tz) === "2026-02", "Monat in Mandanten-Zeitzone"); const march = monthBounds("2026-03", tz); ok(march.from.toISOString() === "2026-02-28T23:00:00.000Z" && march.to.toISOString() === "2026-03-31T22:00:00.000Z", "Monatsgrenzen inkl. Sommerzeit"); const conv = await prisma.lotseConversation.create({ data: { tenantId: A.tenantId, userId: u.tech2.id } }); const msg = (createdAt: string, role: "user" | "assistant" = "user") => ({ tenantId: A.tenantId, conversationId: conv.id, role, text: "x", createdAt: new Date(createdAt) }); await prisma.lotseMessage.createMany({ data: [msg("2026-02-28T22:30:00Z"), msg("2026-02-28T23:30:00Z"), msg("2026-02-28T23:31:00Z", "assistant"), msg("2026-03-31T21:59:00Z"), msg("2026-03-31T22:00:00Z")], }); const feb = await getChatUsage(A.ctx.admin, { monthKey: "2026-02" }); const mar = await getChatUsage(A.ctx.admin, { monthKey: "2026-03" }); const apr = await getChatUsage(A.ctx.admin, { monthKey: "2026-04" }); ok(feb.used === 1 && mar.used === 2 && apr.used === 1, `Zählung je Monat (Feb ${feb.used}, Mär ${mar.used}, Apr ${apr.used}); Lotse-Antworten zählen nicht`); ok(mar.perUser.length === 1 && mar.perUser[0].userId === u.tech2.id && mar.perUser[0].count === 2, "Aufschlüsselung je Nutzer"); section("Mehrverbrauch weiterzählen, hartes Limit"); const now = new Date(); const current = await getChatUsage(A.ctx.admin, { now }); ok(current.quota === 300 && current.quotaBasis === 2, `Kontingent = 2 vergebene Plätze × 150 (${current.quota})`); const fill = 300 - current.used; const recent = new Date(Math.max(new Date(current.from).getTime() + 60_000, now.getTime() - 60_000)); await prisma.lotseMessage.createMany({ data: Array.from({ length: fill + 5 }, () => ({ tenantId: A.tenantId, conversationId: conv.id, role: "user" as const, text: "y", createdAt: recent })) }); const overUsage = await getChatUsage(A.ctx.admin, { now }); ok(overUsage.used === 305 && overUsage.overage === 5 && overUsage.overagePacks === 1 && overUsage.level === "over" && !overUsage.blocked, "305/300: Mehrverbrauch 5 → 1 Paket, nicht gesperrt"); ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "ok", "ohne hartes Limit: Senden weiter möglich"); const sent = await sendLotseMessage(A.ctx.tech, { text: "Noch eine Frage" }, { provider: provider(), now: () => now }); ok(sent.messages.length > 0 && (await getChatUsage(A.ctx.admin, { now })).used === 306, "Mehrverbrauch wird weitergezählt (306)"); await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: true }); ok(await isChatQuotaBlocked(A.ctx.tech, now), "hartes Limit → Kontingent gesperrt"); ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "blocked/quota_exhausted", "Senden → blocked quota_exhausted"); const beforeCount = await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } }); ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Gesperrt?" }, { provider: provider(), now: () => now }))) === "blocked/quota_exhausted", "sendLotseMessage → quota_exhausted"); ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === beforeCount, "gesperrte Nachricht wird nicht gespeichert/gezählt"); ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Verlauf bleibt lesbar (Hinweis statt Eingabefeld)"); ok(!(await isChatQuotaBlocked(B.ctx.tech, now)), "hartes Limit von A wirkt nicht auf B"); section("Testphase: ohne Platzprüfung, Kontingent = Berechtigte × 150"); await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "TRIAL", trialStartedAt: new Date(), trialEndsAt: new Date(Date.now() + 10 * 86400_000), tier: "BASIS", lotseChatHardLimit: true } }); ok((await canUseLotseChat(B.ctx.tech)) && (await canUseLotseChat(B.ctx.outsider)), "Testphase: jeder berechtigte Nutzer darf chatten (auch bei gespeicherter Stufe Basis)"); const trialUsage = await getChatUsage(B.ctx.admin, { now }); const eligibleB = (await listEligibleUsers(B.ctx.admin)).length; ok(trialUsage.isTrial && trialUsage.quota === eligibleB * 150 && !trialUsage.hardLimit, `Testphase: Kontingent ${eligibleB} × 150, kein hartes Limit`); ok((await codeOf(sendLotseMessage(B.ctx.outsider, { text: "Test" }, { provider: provider() }))) === "ok", "Testphase: Senden ohne Platz"); ok((await codeOf(assertLotseChatUsable(B.ctx.backoffice))) === "forbidden", "Testphase: ohne Feldrolle weiterhin kein Chat"); await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "FULL", trialStartedAt: null, trialEndsAt: null, tier: "PROFI", lotseChatHardLimit: false } }); section("Basis sperrt den Chat, Plätze bleiben"); await updateTenantPlan(actor, A.tenantId, { tier: "BASIS", lotseChatSeats: 2, lotseChatHardLimit: false }); ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/not_in_plan", "Basis → blocked not_in_plan"); ok(!(await canUseLotseChat(A.ctx.tech)), "Basis: kein „Lotse fragen“/Navigationseintrag"); ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Plätze bleiben gespeichert"); ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }))) === "blocked/not_in_plan", "Basis: keine neue Vergabe"); await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false }); ok(await canUseLotseChat(A.ctx.tech), "zurück auf Profi: Chat mit bestehendem Platz wieder nutzbar"); section("Betreiber-Übersicht (Grundlage der Rechnung)"); const op = await getTenantPlanOverview(actor, A.tenantId, now); ok(op.plan.lotseChatSeats === 2 && op.seatsAssigned === 2 && op.current.used === 306 && op.current.overage === 6 && op.current.overagePacks === 1, `laufender Monat: 306 Chats, Mehrverbrauch 6 → 1 Paket`); ok(op.previous.monthKey !== op.current.monthKey && op.previous.used >= 0, `Vormonat ${op.previous.monthKey}: ${op.previous.used} Chats`); await expectCode(() => getTenantPlanOverview({ platformAdminId: u.admin.id }, A.tenantId), "forbidden", "Mandanten-Admin hat keine Betreiber-Übersicht"); } finally { await extraCleanup([A.tenantId, B.tenantId]); } });