- 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>
150 lines
8.4 KiB
TypeScript
150 lines
8.4 KiB
TypeScript
// Shared fixture of the lane L16 tests (scripts/test-lotse-chat-*.ts): two zz test tenants with
|
|
// technicians (assigned / not assigned), admin, tenant B, customers with contact data (for the data
|
|
// minimisation checks) and work orders in different statuses. Not a test itself.
|
|
|
|
import { prisma, dbForTenant } from "../../src/server/db";
|
|
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
|
import { ServiceError, type ServiceCtx } from "../../src/server/services/context";
|
|
|
|
export let failures = 0;
|
|
export const ok = (cond: boolean, msg: string) => {
|
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
export async function expectErr(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string, reason?: string) {
|
|
try {
|
|
await fn();
|
|
ok(false, `${msg} — kein Fehler (erwartet ${code}${reason ? `/${reason}` : ""})`);
|
|
} catch (err) {
|
|
if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`);
|
|
const r = (err.details as { reason?: string } | undefined)?.reason;
|
|
ok(err.code === code && (!reason || r === reason), `${msg} (${err.code}${r ? `/${r}` : ""})`);
|
|
}
|
|
}
|
|
|
|
export const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
|
db: dbForTenant(tenantId),
|
|
tenantId,
|
|
userId,
|
|
permissions: new Set<string>(ROLE_DEFS[role].permissions),
|
|
});
|
|
|
|
export async function cleanupChatTenants(prefix: string) {
|
|
const slugs = [`zz-${prefix}-a`, `zz-${prefix}-b`];
|
|
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
|
|
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);
|
|
await prisma.notification.deleteMany(w);
|
|
await prisma.mailLog.deleteMany(w);
|
|
await prisma.aiGeneration.deleteMany(w);
|
|
await prisma.signature.deleteMany(w);
|
|
await prisma.report.deleteMany(w);
|
|
await prisma.activityNote.deleteMany(w);
|
|
await prisma.voiceNote.deleteMany(w);
|
|
await prisma.materialUsage.deleteMany(w);
|
|
await prisma.materialPlan.deleteMany(w);
|
|
await prisma.timeEntry.deleteMany(w);
|
|
await prisma.workSession.deleteMany(w);
|
|
await prisma.photo.deleteMany(w);
|
|
await prisma.checklistItem.deleteMany(w);
|
|
await prisma.photoRequirement.deleteMany(w);
|
|
await prisma.workOrderStatusChange.deleteMany(w);
|
|
await prisma.workOrderAssignee.deleteMany(w);
|
|
await prisma.syncOperation.deleteMany(w);
|
|
await prisma.document.deleteMany(w);
|
|
await prisma.workOrder.deleteMany(w);
|
|
await prisma.site.deleteMany(w);
|
|
await prisma.contact.deleteMany(w);
|
|
await prisma.customer.deleteMany(w);
|
|
await prisma.numberSequence.deleteMany(w);
|
|
await prisma.auditLog.deleteMany(w);
|
|
await prisma.tenantModule.deleteMany(w);
|
|
await prisma.tenantSettings.deleteMany(w);
|
|
await prisma.user.deleteMany(w);
|
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
|
}
|
|
await prisma.identity.deleteMany({ where: { email: { endsWith: `@zz-${prefix}.test` } } });
|
|
}
|
|
|
|
export async function createChatFixture(prefix: string) {
|
|
await cleanupChatTenants(prefix);
|
|
const domain = `@zz-${prefix}.test`;
|
|
const tA = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} A`, slug: `zz-${prefix}-a` } });
|
|
const tB = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} B`, slug: `zz-${prefix}-b` } });
|
|
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Chat Test A GmbH", phone: "040 1234567", email: "buero@lotsechat-a.test", address: "Werftstraße 5, 20457 Hamburg" } });
|
|
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Chat Test B" } });
|
|
|
|
const mkUser = async (tenantId: string, key: string, name: string) => {
|
|
const identity = await prisma.identity.create({ data: { email: `${key}${domain}`, passwordHash: "x" } });
|
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
|
|
};
|
|
const tech = await mkUser(tA.id, "tech", "Max Monteur");
|
|
const tech2 = await mkUser(tA.id, "tech2", "Nora Nordmann");
|
|
const outsider = await mkUser(tA.id, "outsider", "Otto Fremd");
|
|
const admin = await mkUser(tA.id, "admin", "Anna Admin");
|
|
const techB = await mkUser(tB.id, "techb", "Tom Bader");
|
|
|
|
const now = Date.now();
|
|
const today = { plannedStart: new Date(now - 3_600_000), plannedEnd: new Date(now + 3 * 3_600_000) };
|
|
const tomorrow = { plannedStart: new Date(now + 26 * 3_600_000), plannedEnd: new Date(now + 30 * 3_600_000) };
|
|
|
|
const customer = await prisma.customer.create({
|
|
data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" },
|
|
});
|
|
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } });
|
|
const site = await prisma.site.create({
|
|
data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg", accessNotes: "Schlüsselkasten Code 4711, Hausmeister 0170 1112223" },
|
|
});
|
|
const company = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Müller Haustechnik GmbH", city: "Buxtehude" } });
|
|
const site2 = await prisma.site.create({ data: { tenantId: tA.id, customerId: company.id, name: "Objekt Müller", city: "Buxtehude" } });
|
|
|
|
const order = async (number: string, data: Record<string, unknown>, assignees: string[]) => {
|
|
const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number, ...(data as object) } as never });
|
|
for (const userId of assignees) await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId } });
|
|
return wo as unknown as { id: string; number: string; status: string };
|
|
};
|
|
const wo1 = await order(
|
|
"A-LC-1",
|
|
{ customerId: customer.id, siteId: site.id, contactId: contact.id, title: "Speicher tauschen", description: "Kundin unter 0171 9876543 oder erika@kunde.test erreichbar, Lindenallee 7.", status: "in_progress", signatureRequired: false, ...today },
|
|
[tech.id, tech2.id],
|
|
);
|
|
const plan = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo1.id, name: "Filter", plannedQuantity: 1, unit: "Stk" } });
|
|
const wo2 = await order("A-LC-2", { customerId: company.id, siteId: site2.id, title: "Wartung Heizung", status: "assigned", signatureRequired: false, ...today }, [tech.id, tech2.id]);
|
|
const wo3 = await order("A-LC-3", { customerId: company.id, siteId: site2.id, title: "Pumpe montieren", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
|
|
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 } });
|
|
|
|
return {
|
|
tA,
|
|
tB,
|
|
users: { tech, tech2, outsider, admin, techB },
|
|
ctx: {
|
|
tech: ctxOf(tA.id, tech.id, "technician"),
|
|
tech2: ctxOf(tA.id, tech2.id, "technician"),
|
|
outsider: ctxOf(tA.id, outsider.id, "technician"),
|
|
admin: ctxOf(tA.id, admin.id, "tenant-admin"),
|
|
techB: ctxOf(tB.id, techB.id, "technician"),
|
|
},
|
|
customer,
|
|
contact,
|
|
site,
|
|
plan,
|
|
orders: { wo1, wo2, wo3, wo4, woB },
|
|
};
|
|
}
|