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:
@@ -61,6 +61,8 @@ const ACTION_MODULE: Record<string, string> = {
|
||||
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
|
||||
"trial-platform.ts": "EXEMPT",
|
||||
"trial-tenant.ts": "EXEMPT",
|
||||
// L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin)
|
||||
"plans-platform.ts": "EXEMPT",
|
||||
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
|
||||
"trial-signup.ts": "PUBLIC",
|
||||
};
|
||||
|
||||
@@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
|
||||
await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
|
||||
|
||||
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
|
||||
return { orders, created };
|
||||
}
|
||||
|
||||
/**
|
||||
* L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
|
||||
* existing seats of other users are left alone.
|
||||
*/
|
||||
export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise<void> {
|
||||
await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
|
||||
const db = dbForTenant(tenantId);
|
||||
for (const userId of opts.seatUserIds ?? []) {
|
||||
const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
|
||||
if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
|
||||
}
|
||||
}
|
||||
|
||||
/** Isolation fixture for the second tenant: one customer + one work order. */
|
||||
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
|
||||
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
|
||||
@@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr
|
||||
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
|
||||
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
|
||||
}
|
||||
// L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
|
||||
await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
|
||||
}
|
||||
|
||||
/** Close the owner client used by the services (the seed has its own client). */
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise<void> {
|
||||
await prisma.mailLog.deleteMany(w);
|
||||
await prisma.authToken.deleteMany(w);
|
||||
await prisma.aiGeneration.deleteMany(w);
|
||||
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function cleanupChatTenants(prefix: string) {
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
const w = { where: { tenantId: { in: ids } } };
|
||||
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
|
||||
await prisma.lotseActionProposal.deleteMany(w);
|
||||
await prisma.lotseMessage.deleteMany(w);
|
||||
await prisma.lotseConversation.deleteMany(w);
|
||||
@@ -119,6 +120,11 @@ export async function createChatFixture(prefix: string) {
|
||||
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } });
|
||||
const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
|
||||
|
||||
// L17 Pakete: the chat needs a seat per user (Profi is the default tier)
|
||||
await prisma.tenant.updateMany({ where: { id: { in: [tA.id, tB.id] } }, data: { lotseChatSeats: 10 } });
|
||||
for (const u of [tech, tech2, outsider, admin]) await prisma.lotseChatSeat.create({ data: { tenantId: tA.id, userId: u.id } });
|
||||
await prisma.lotseChatSeat.create({ data: { tenantId: tB.id, userId: techB.id } });
|
||||
|
||||
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
|
||||
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } });
|
||||
await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
|
||||
|
||||
@@ -142,6 +142,7 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
|
||||
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
|
||||
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
|
||||
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
|
||||
put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im
|
||||
// moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState,
|
||||
// requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.),
|
||||
// isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust;
|
||||
// nur der Betreiber ändert die Stufe.
|
||||
// Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true)
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Session } from "next-auth";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules";
|
||||
import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan";
|
||||
import { toErrorResponse } from "../src/server/api/respond";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { getPlanningBoard } from "../src/server/services/planning/board";
|
||||
import { getLiveSituation } from "../src/server/services/planning/live";
|
||||
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
|
||||
import { recommendAssignments } from "../src/server/services/planning/recommend";
|
||||
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
|
||||
import { getPlanningToday } from "../src/server/services/planning/summary";
|
||||
import { runPlanningWatch } from "../src/server/services/planning/watch";
|
||||
import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings";
|
||||
import { billingModuleActive } from "../src/server/services/work-orders/transition";
|
||||
import { updateTenantPlan } from "../src/server/services/plans/platform";
|
||||
import { problemKey } from "../src/lib/offline/outbox-core";
|
||||
import type { SyncOperationInput } from "../src/lib/sync/envelope";
|
||||
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-pak-gates-a";
|
||||
const SLUG_B = "zz-pak-gates-b";
|
||||
const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test";
|
||||
const READONLY_EMAIL = "readonly-pakete@zz-pakete.test";
|
||||
|
||||
const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session;
|
||||
const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
|
||||
|
||||
function emergencyOp(): SyncOperationInput {
|
||||
return {
|
||||
clientOpId: randomUUID(),
|
||||
opType: "emergency.create",
|
||||
payload: {
|
||||
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
|
||||
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
|
||||
reason: "Wasserrohrbruch im Keller",
|
||||
},
|
||||
clientCreatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function extraCleanup(tenantIds: string[]) {
|
||||
await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } });
|
||||
await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } });
|
||||
await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } });
|
||||
}
|
||||
|
||||
runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => {
|
||||
await extraCleanup([]);
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
try {
|
||||
await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } });
|
||||
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
|
||||
const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } });
|
||||
|
||||
section("Stufe am Mandanten");
|
||||
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi");
|
||||
|
||||
section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)");
|
||||
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`);
|
||||
for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`);
|
||||
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`);
|
||||
let redirected = "";
|
||||
try {
|
||||
await requirePlanFeature(A.tenantId, "planning");
|
||||
} catch (err) {
|
||||
redirected = String((err as { digest?: string }).digest ?? (err as Error).message);
|
||||
}
|
||||
ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`);
|
||||
ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect");
|
||||
const modulesSrc = src("src/server/modules.ts");
|
||||
ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis");
|
||||
ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature");
|
||||
|
||||
section("Action (moduleGuard → assertModuleEnabled)");
|
||||
let actionErr: unknown = null;
|
||||
try {
|
||||
await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing");
|
||||
} catch (err) {
|
||||
actionErr = err;
|
||||
}
|
||||
ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)");
|
||||
const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } });
|
||||
ok(!!denied, "verweigerter Zugriff wird auditiert");
|
||||
for (const key of ["emergency", "imports", "lotse"]) {
|
||||
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`);
|
||||
}
|
||||
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt");
|
||||
ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt");
|
||||
ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled");
|
||||
|
||||
section("API (requireApiContext → 403 mit Klartext)");
|
||||
ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled");
|
||||
const res = toErrorResponse(actionErr);
|
||||
const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } };
|
||||
ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext");
|
||||
const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e);
|
||||
const planningRes = toErrorResponse(planningErr);
|
||||
ok(planningRes.status === 403, "Planungs-API (Basis) → 403");
|
||||
|
||||
section("Planung (Feature ohne eigenes Modul)");
|
||||
ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja");
|
||||
await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden");
|
||||
await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden");
|
||||
const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } });
|
||||
await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden");
|
||||
await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden");
|
||||
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden");
|
||||
ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet");
|
||||
const watch = await runPlanningWatch(A.tenantId);
|
||||
ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen");
|
||||
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt");
|
||||
ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt");
|
||||
|
||||
section("Lotse, Abrechnung, Navigation");
|
||||
ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an");
|
||||
ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)");
|
||||
const effA = await effectiveModules(A.tenantId);
|
||||
ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung");
|
||||
const effB = await effectiveModules(B.tenantId);
|
||||
ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar");
|
||||
|
||||
section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)");
|
||||
const opA = emergencyOp();
|
||||
const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
|
||||
const rA = syncA.results[0];
|
||||
ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`);
|
||||
const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } });
|
||||
ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt");
|
||||
const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
|
||||
ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)");
|
||||
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)");
|
||||
const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } });
|
||||
const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() };
|
||||
const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0];
|
||||
ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected");
|
||||
const opB = emergencyOp();
|
||||
const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0];
|
||||
ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`);
|
||||
|
||||
section("TenantModule aus + Profi → gesperrt");
|
||||
await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } });
|
||||
ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled");
|
||||
let offErr: unknown = null;
|
||||
try {
|
||||
await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency");
|
||||
} catch (err) {
|
||||
offErr = err;
|
||||
}
|
||||
ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)");
|
||||
const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0];
|
||||
ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)");
|
||||
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled");
|
||||
await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } });
|
||||
ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv");
|
||||
|
||||
section("Nur der Betreiber ändert die Stufe");
|
||||
await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden");
|
||||
await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden");
|
||||
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid");
|
||||
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid");
|
||||
await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" });
|
||||
const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
|
||||
ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze");
|
||||
ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze");
|
||||
|
||||
section("Wechsel Profi → Basis → Profi ohne Datenverlust");
|
||||
await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
|
||||
const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
|
||||
await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } });
|
||||
const snapshot = async () => ({
|
||||
emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }),
|
||||
seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }),
|
||||
messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }),
|
||||
teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }),
|
||||
});
|
||||
const before = await snapshot();
|
||||
const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true });
|
||||
ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit");
|
||||
const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } });
|
||||
ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after");
|
||||
ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet");
|
||||
await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt");
|
||||
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert");
|
||||
await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false });
|
||||
ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da");
|
||||
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden");
|
||||
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt");
|
||||
|
||||
section("Mandantentrennung der Stufe");
|
||||
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt");
|
||||
ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht");
|
||||
} finally {
|
||||
await extraCleanup([A.tenantId, B.tenantId]);
|
||||
await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
|
||||
await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// L17 Pakete — reine Stufenregeln (src/lib/plans.ts), ohne Datenbank.
|
||||
// Lauf: npx tsx scripts/test-pakete-rules.ts
|
||||
|
||||
import {
|
||||
chatOverage,
|
||||
chatQuota,
|
||||
DEFAULT_TENANT_TIER,
|
||||
effectiveTier,
|
||||
isFeatureInTier,
|
||||
isModuleInTier,
|
||||
isQuotaBlocked,
|
||||
isTenantTier,
|
||||
LOTSE_CHAT_CHATS_PER_SEAT,
|
||||
LOTSE_CHAT_OVERAGE_PACK,
|
||||
modulesLockedByTier,
|
||||
monthKeyOfDay,
|
||||
nextMonthKey,
|
||||
overagePacks,
|
||||
previousMonthKey,
|
||||
PROFI_ONLY_MODULES,
|
||||
usageLevel,
|
||||
usagePercent,
|
||||
} from "../src/lib/plans";
|
||||
import { MODULE_KEYS } from "../src/lib/modules";
|
||||
import { NAV_ITEMS, visibleNavItems } from "../src/lib/nav";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
console.log("— Stufen —");
|
||||
ok(DEFAULT_TENANT_TIER === "PROFI", "Default-Stufe ist Profi");
|
||||
ok(isTenantTier("BASIS") && isTenantTier("PROFI") && !isTenantTier("GOLD") && !isTenantTier(null), "isTenantTier erkennt nur BASIS/PROFI");
|
||||
ok(effectiveTier("BASIS", "TRIAL") === "PROFI", "Testphase läuft immer als Profi");
|
||||
ok(effectiveTier("BASIS", "FULL") === "BASIS" && effectiveTier("PROFI", "FULL") === "PROFI", "Vollversion: gespeicherte Stufe gilt");
|
||||
ok(effectiveTier(undefined, "FULL") === "PROFI", "unbekannte Stufe → Default Profi");
|
||||
|
||||
console.log("\n— Module je Stufe —");
|
||||
const profiOnly = ["billing", "emergency", "imports", "lotse"];
|
||||
ok(JSON.stringify([...PROFI_ONLY_MODULES].sort()) === JSON.stringify([...profiOnly].sort()), "Nur-Profi-Module: billing, emergency, imports, lotse");
|
||||
for (const key of MODULE_KEYS) {
|
||||
ok(isModuleInTier("PROFI", key), `Profi enthält ${key}`);
|
||||
ok(isModuleInTier("BASIS", key) === !profiOnly.includes(key), `Basis ${profiOnly.includes(key) ? "sperrt" : "enthält"} ${key}`);
|
||||
}
|
||||
ok(modulesLockedByTier("PROFI").length === 0 && modulesLockedByTier("BASIS").length === 4, "modulesLockedByTier");
|
||||
ok(!isFeatureInTier("BASIS", "planning") && !isFeatureInTier("BASIS", "lotse_chat"), "Basis: keine Planung, kein Lotse-Chat");
|
||||
ok(isFeatureInTier("PROFI", "planning") && isFeatureInTier("PROFI", "lotse_chat"), "Profi: Planung und Lotse-Chat");
|
||||
|
||||
console.log("\n— Kontingent —");
|
||||
ok(LOTSE_CHAT_CHATS_PER_SEAT === 150 && LOTSE_CHAT_OVERAGE_PACK === 100, "150 Chats je Platz, Mehrverbrauch in Paketen à 100");
|
||||
ok(chatQuota(3) === 450 && chatQuota(0) === 0 && chatQuota(-2) === 0, "Kontingent = Plätze × 150");
|
||||
ok(chatOverage(212, 450) === 0 && chatOverage(460, 450) === 10, "Mehrverbrauch = über dem Kontingent");
|
||||
ok(overagePacks(0) === 0 && overagePacks(1) === 1 && overagePacks(100) === 1 && overagePacks(101) === 2 && overagePacks(250) === 3, "angefangene 100er-Pakete zählen");
|
||||
ok(usageLevel(212, 450) === "ok", "212/450 → ok");
|
||||
ok(usageLevel(360, 450) === "warn" && usageLevel(359, 450) === "ok", "ab 80 % → warn");
|
||||
ok(usageLevel(450, 450) === "exhausted" && usageLevel(451, 450) === "over", "ausgeschöpft / Mehrverbrauch");
|
||||
ok(usageLevel(0, 0) === "ok" && usageLevel(1, 0) === "over", "ohne Kontingent zählt jede Nutzung als Mehrverbrauch");
|
||||
ok(usagePercent(212, 450) === 47 && usagePercent(900, 450) === 100 && usagePercent(0, 0) === 0, "Balken in Prozent (gedeckelt)");
|
||||
ok(!isQuotaBlocked(500, 450, false), "ohne hartes Limit wird weitergezählt");
|
||||
ok(isQuotaBlocked(450, 450, true) && !isQuotaBlocked(449, 450, true), "hartes Limit sperrt ab dem Kontingent");
|
||||
|
||||
console.log("\n— Monate —");
|
||||
ok(monthKeyOfDay("2026-09-21") === "2026-09", "Monat eines Tags");
|
||||
ok(previousMonthKey("2026-01") === "2025-12" && previousMonthKey("2026-09") === "2026-08", "Vormonat inkl. Jahreswechsel");
|
||||
ok(nextMonthKey("2026-12") === "2027-01" && nextMonthKey("2026-09") === "2026-10", "Folgemonat inkl. Jahreswechsel");
|
||||
|
||||
console.log("\n— Navigation —");
|
||||
const all = NAV_ITEMS.flatMap((i) => i.permissions ?? []);
|
||||
const basisNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(profiOnly), lockedFeatures: new Set(["planning", "lotse_chat"]), permissions: all });
|
||||
const profiNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(), lockedFeatures: new Set(), permissions: all });
|
||||
ok(!basisNav.some((i) => i.href.startsWith("/planning")), "Basis: keine Planung in der Navigation");
|
||||
ok(!basisNav.some((i) => ["/billing", "/imports", "/work-orders/emergency-review"].includes(i.href)), "Basis: keine Abrechnung/Import/Notdienst-Prüfung");
|
||||
ok(basisNav.some((i) => i.href === "/work-orders") && basisNav.some((i) => i.href === "/work-orders/time-approvals") && basisNav.some((i) => i.href === "/reports"), "Basis: Aufträge, Zeitfreigabe, Berichte bleiben");
|
||||
ok(profiNav.some((i) => i.href === "/planning") && profiNav.some((i) => i.href === "/billing"), "Profi: Planung und Abrechnung sichtbar");
|
||||
|
||||
console.log(failures === 0 ? "\nAlle Prüfungen grün." : `\n${failures} FEHLER.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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<unknown>): Promise<string> {
|
||||
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]);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user