Files
craftvia/scripts/lib/lotse-chat-fixture.ts
T
msolarczekandClaude Opus 5 06d320908f 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>
2026-09-21 10:13:27 +02:00

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 },
};
}