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:
2026-09-21 10:13:27 +02:00
co-authored by Claude Opus 5
parent 4d5be72ebb
commit 06d320908f
60 changed files with 2076 additions and 91 deletions
+6 -2
View File
@@ -317,11 +317,15 @@
"transition_forbidden": "Dafür fehlt die Berechtigung.",
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
"reason_required": "Der Grund fehlt.",
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich."
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich.",
"not_in_plan": "Der Lotse ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet.",
"no_seat": "Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.",
"quota_exhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht."
},
"unavailable": {
"title": "Lotse-Chat nicht verfügbar",
"back": "Zur Übersicht"
}
},
"quotaExhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht. Ab dem nächsten Monat kannst du wieder schreiben – bei Fragen hilft das Büro."
}
}
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technischer Fehler – wird automatisch erneut versucht.",
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln."
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.",
"notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.",
"moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden."
},
"view": {
"title": "Offline-Ansicht",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basis",
"PROFI": "Profi"
},
"colTier": "Paket",
"notInTier": "Nur im Paket Profi",
"profiHint": {
"title": "Im Paket Profi enthalten",
"text": "Diese Funktion gehört zum Paket Profi. Ihr Betrieb nutzt das Paket Basis – für ein Upgrade wenden Sie sich bitte an Ihren Craftvia-Ansprechpartner."
},
"admin": {
"title": "Paket & Lotse-Chat",
"sub": "Paket des Betriebs, Chat-Plätze für Monteure und Verbrauch im laufenden Monat",
"package": "Paket",
"trial": "Testphase (alle Funktionen)",
"packageReadOnly": "Paket und Anzahl der Chat-Plätze legt der Anbieter fest. Hier vergeben Sie die gekauften Plätze an Ihre Monteure und Teamleiter.",
"basisHint": "Lotse und Lotse-Chat sind im Paket Profi enthalten. Bereits vergebene Plätze und Chatverläufe bleiben gespeichert und sind nach einem Wechsel zu Profi wieder verfügbar.",
"seatsTitle": "Chat-Plätze",
"seatsCount": "{assigned} von {purchased} Plätzen vergeben",
"trialSeats": "Während der Testphase dürfen alle Monteure und Teamleiter den Lotse-Chat nutzen – Plätze müssen nicht vergeben werden (Kontingent: {perSeat} Chats je Person und Monat).",
"noSeatsBought": "Noch keine Chat-Plätze gebucht. Plätze bucht der Anbieter für Ihren Betrieb.",
"overbooked": "Mehr Plätze vergeben als gebucht ({purchased}). Nur die zuerst vergebenen Plätze gelten – bitte Plätze entziehen.",
"noFieldUsers": "Keine aktiven Nutzer mit Feldrolle (Monteur, Teamleiter).",
"seatAssigned": "Platz vergeben",
"seatOverbooked": "Über der Platzanzahl",
"seatNone": "Kein Platz",
"assign": "Platz vergeben",
"revoke": "Platz entziehen",
"assignLabel": "Chat-Platz an {name} vergeben",
"revokeLabel": "Chat-Platz von {name} entziehen",
"saved": "Chat-Platz gespeichert.",
"usageTitle": "Verbrauch {month}",
"usageText": "{used} von {quota} Chats",
"quotaRule": "Kontingent: {perSeat} Chats je vergebenem Platz und Monat, gemeinsam für den Betrieb (Basis: {basis}). Ein Chat ist eine Nachricht an den Lotsen.",
"warn80": "{percent} % des Monatskontingents sind genutzt.",
"exhausted": "Das Monatskontingent ist ausgeschöpft. Weitere Chats werden als Mehrverbrauch in Paketen à {pack} berechnet.",
"hardLimitReached": "Das Monatskontingent ist ausgeschöpft. Der Lotse-Chat ist bis zum Monatsende gesperrt (hartes Limit).",
"overage": "Mehrverbrauch: {overage} Chats – entspricht {packs, plural, one {# Paket} other {# Paketen}} à {pack} Chats.",
"perUserCaption": "Chats je Nutzer im laufenden Monat",
"perUserName": "Nutzer",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "Alle gebuchten Plätze sind vergeben. Bitte zuerst einen Platz entziehen oder weitere Plätze beim Anbieter buchen.",
"not_eligible": "Diese Person kann keinen Chat-Platz erhalten (keine aktive Feldrolle).",
"not_in_plan": "Der Lotse-Chat ist im Paket Profi enthalten.",
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
"forbidden": "Dafür fehlt die Berechtigung.",
"trial_expired": "Testphase abgelaufen – nur Lesezugriff.",
"failed": "Speichern nicht möglich."
}
},
"platform": {
"title": "Paket & Lotse-Chat",
"tier": "Paket",
"trialProfi": "Testphase = Profi",
"seats": "Chat-Plätze",
"seatsValue": "{assigned} vergeben / {purchased} gebucht",
"hardLimit": "Hartes Limit",
"hardLimitOn": "an – Sperre beim Kontingent",
"hardLimitOff": "aus – Mehrverbrauch wird gezählt",
"rule": "{perSeat} Chats je vergebenem Platz und Monat (Zeitzone des Mandanten); Mehrverbrauch in Paketen à {pack}.",
"currentMonth": "Laufender Monat · {month}",
"previousMonth": "Vormonat · {month}",
"chats": "{used} von {quota} Chats",
"overage": "Mehrverbrauch {overage} Chats → {packs, plural, one {# Paket} other {# Pakete}} à {pack}",
"noOverage": "Kein Mehrverbrauch",
"edit": "Paket ändern …",
"editTitle": "Paket & Chat-Plätze ändern",
"editText": "Die Änderung wirkt sofort für alle Nutzer des Mandanten und wird im Plattform-Audit protokolliert.",
"tierHint": {
"BASIS": "Ohne KI und Planung: kein Lotse, keine Plantafel/Live-Lage, keine Abrechnungsübersicht, kein Notdienst, kein Auftragsimport.",
"PROFI": "Alle Funktionen inkl. Planung, Abrechnungsübersicht, Notdienst, Import und Lotse."
},
"downgradeHint": "Wechsel zu Basis: Daten bleiben erhalten, nur der Zugriff endet. Zurück zu Profi ist alles wieder da.",
"seatsInput": "Gebuchte Chat-Plätze",
"seatsHint": "Der Mandanten-Admin vergibt die Plätze an Nutzer mit Feldrolle.",
"hardLimitInput": "Hartes Limit",
"hardLimitHint": "Nach Ausschöpfen des Monatskontingents wird der Chat gesperrt statt Mehrverbrauch zu zählen.",
"confirm": "Ich habe die Auswirkungen geprüft und bestätige die Änderung.",
"submit": "Speichern",
"submitting": "Speichern …",
"close": "Schließen",
"saved": "Paket gespeichert.",
"errors": {
"confirm_required": "Bitte die Änderung bestätigen.",
"invalid_input": "Bitte Eingaben prüfen (Plätze 0–1000).",
"tenant_archived": "Der Mandant ist archiviert.",
"platform_admin_required": "Nur Voll-Administratoren dürfen das Paket ändern.",
"failed": "Speichern nicht möglich."
}
}
}
+6 -2
View File
@@ -317,11 +317,15 @@
"transition_forbidden": "You lack the permission.",
"not_editable": "The report has been submitted and can no longer be changed.",
"reason_required": "The reason is missing.",
"use_billing_overview": "Only possible via the billing overview."
"use_billing_overview": "Only possible via the billing overview.",
"not_in_plan": "The Lotse is included in the Profi package and not activated for this company.",
"no_seat": "The Lotse chat is not activated for you yet – please ask the office.",
"quota_exhausted": "The company's chat quota for this month is used up."
},
"unavailable": {
"title": "Lotse chat not available",
"back": "Back to overview"
}
},
"quotaExhausted": "The company's chat quota for this month is used up. You can write again from next month – the office can help with questions."
}
}
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technical error – will retry automatically.",
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch."
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.",
"notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.",
"moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office."
},
"view": {
"title": "Offline view",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basic",
"PROFI": "Profi"
},
"colTier": "Package",
"notInTier": "Profi package only",
"profiHint": {
"title": "Included in the Profi package",
"text": "This function is part of the Profi package. Your company uses the Basic package – please contact your Craftvia representative to upgrade."
},
"admin": {
"title": "Package & Lotse chat",
"sub": "Package of the company, chat seats for technicians and usage in the current month",
"package": "Package",
"trial": "Trial (all functions)",
"packageReadOnly": "The provider sets the package and the number of chat seats. Here you assign the purchased seats to your technicians and team leads.",
"basisHint": "Lotse and the Lotse chat are included in the Profi package. Assigned seats and chat histories are kept and available again after switching to Profi.",
"seatsTitle": "Chat seats",
"seatsCount": "{assigned} of {purchased} seats assigned",
"trialSeats": "During the trial all technicians and team leads may use the Lotse chat – no seats need to be assigned (quota: {perSeat} chats per person and month).",
"noSeatsBought": "No chat seats purchased yet. The provider books seats for your company.",
"overbooked": "More seats assigned than purchased ({purchased}). Only the seats assigned first are valid – please revoke seats.",
"noFieldUsers": "No active users with a field role (technician, team lead).",
"seatAssigned": "Seat assigned",
"seatOverbooked": "Above the seat number",
"seatNone": "No seat",
"assign": "Assign seat",
"revoke": "Revoke seat",
"assignLabel": "Assign chat seat to {name}",
"revokeLabel": "Revoke chat seat from {name}",
"saved": "Chat seat saved.",
"usageTitle": "Usage {month}",
"usageText": "{used} of {quota} chats",
"quotaRule": "Quota: {perSeat} chats per assigned seat and month, shared by the company (basis: {basis}). One chat is one message to the Lotse.",
"warn80": "{percent} % of the monthly quota are used.",
"exhausted": "The monthly quota is used up. Further chats are billed as overage in packs of {pack}.",
"hardLimitReached": "The monthly quota is used up. The Lotse chat is blocked until the end of the month (hard limit).",
"overage": "Overage: {overage} chats – equals {packs, plural, one {# pack} other {# packs}} of {pack} chats.",
"perUserCaption": "Chats per user in the current month",
"perUserName": "User",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "All purchased seats are assigned. Please revoke a seat first or book more seats with the provider.",
"not_eligible": "This person cannot get a chat seat (no active field role).",
"not_in_plan": "The Lotse chat is included in the Profi package.",
"disabled": "Lotse is switched off for this company.",
"forbidden": "You are not allowed to do this.",
"trial_expired": "Trial expired – read-only access.",
"failed": "Could not save."
}
},
"platform": {
"title": "Package & Lotse chat",
"tier": "Package",
"trialProfi": "Trial = Profi",
"seats": "Chat seats",
"seatsValue": "{assigned} assigned / {purchased} purchased",
"hardLimit": "Hard limit",
"hardLimitOn": "on – blocked at the quota",
"hardLimitOff": "off – overage is counted",
"rule": "{perSeat} chats per assigned seat and month (tenant timezone); overage in packs of {pack}.",
"currentMonth": "Current month · {month}",
"previousMonth": "Previous month · {month}",
"chats": "{used} of {quota} chats",
"overage": "Overage {overage} chats → {packs, plural, one {# pack} other {# packs}} of {pack}",
"noOverage": "No overage",
"edit": "Change package …",
"editTitle": "Change package & chat seats",
"editText": "The change applies immediately to all users of the tenant and is logged in the platform audit.",
"tierHint": {
"BASIS": "Without AI and planning: no Lotse, no planning board/live situation, no billing overview, no emergency service, no order import.",
"PROFI": "All functions incl. planning, billing overview, emergency service, import and Lotse."
},
"downgradeHint": "Switching to Basic keeps all data, only access ends. Back on Profi everything is available again.",
"seatsInput": "Purchased chat seats",
"seatsHint": "The tenant admin assigns the seats to users with a field role.",
"hardLimitInput": "Hard limit",
"hardLimitHint": "When the monthly quota is used up the chat is blocked instead of counting overage.",
"confirm": "I checked the impact and confirm the change.",
"submit": "Save",
"submitting": "Saving …",
"close": "Close",
"saved": "Package saved.",
"errors": {
"confirm_required": "Please confirm the change.",
"invalid_input": "Please check the input (seats 0–1000).",
"tenant_archived": "The tenant is archived.",
"platform_admin_required": "Only full administrators may change the package.",
"failed": "Could not save."
}
}
}
@@ -0,0 +1,36 @@
-- L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze
-- Additiv: Stufe + Platzanzahl + hartes Limit am Mandanten (Default PROFI = bestehende Mandanten unverändert),
-- Tabelle lotse_chat_seats (Mandanten-RLS), Index für die Monatszählung der Chat-Nachrichten.
-- CreateEnum
CREATE TYPE "TenantTier" AS ENUM ('BASIS', 'PROFI');
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "lotse_chat_hard_limit" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lotse_chat_seats" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "tier" "TenantTier" NOT NULL DEFAULT 'PROFI';
-- CreateTable
CREATE TABLE "lotse_chat_seats" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"assigned_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lotse_chat_seats_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lotse_chat_seats_tenant_id_created_at_idx" ON "lotse_chat_seats"("tenant_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "lotse_chat_seats_tenant_id_user_id_key" ON "lotse_chat_seats"("tenant_id", "user_id");
-- CreateIndex
CREATE INDEX "lotse_messages_tenant_id_role_created_at_idx" ON "lotse_messages"("tenant_id", "role", "created_at");
-- Mandantentrennung (docs/craftvia/MIGRATIONS.md)
SELECT enable_tenant_rls('lotse_chat_seats');
+29
View File
@@ -61,6 +61,13 @@ model Tenant {
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
// L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert)
tier TenantTier @default(PROFI)
/// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin)
lotseChatSeats Int @default(0) @map("lotse_chat_seats")
/// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch
lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit")
users User[]
roles Role[]
auditLogs AuditLog[]
@@ -1505,6 +1512,8 @@ model LotseMessage {
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([tenantId, conversationId, createdAt])
// L17 Pakete: Monatszählung der Chats (role = user) je Mandant
@@index([tenantId, role, createdAt])
@@map("lotse_messages")
}
@@ -1595,3 +1604,23 @@ model TenantExport {
@@index([tenantId, createdAt])
@@map("tenant_exports")
}
// ── L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze ───────────────────────────────
enum TenantTier {
BASIS
PROFI
}
/// Vergebener Lotse-Chat-Platz (Mandanten-Admin vergibt bis zur vom Betreiber gesetzten Anzahl).
model LotseChatSeat {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
assignedById String? @map("assigned_by_id")
createdAt DateTime @default(now()) @map("created_at")
@@unique([tenantId, userId])
@@index([tenantId, createdAt])
@@map("lotse_chat_seats")
}
+2
View File
@@ -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",
};
+18
View File
@@ -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). */
+1
View File
@@ -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);
+6
View File
@@ -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 } });
+1
View File
@@ -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;
}
+213
View File
@@ -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] } } });
}
});
+79
View File
@@ -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);
+191
View File
@@ -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]);
}
});
+7 -3
View File
@@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
import { ProfiHint } from "@/components/plans/profi-hint";
import { effectiveModules } from "@/server/plan";
type SP = Record<string, string | string[] | undefined>;
@@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders");
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
const moduleEnabled = !moduleRow || moduleRow.enabled;
// L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
const modules = await effectiveModules(ctx.tenantId);
const moduleEnabled = !modules.inactive.has("work_orders");
const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
@@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
{t("moduleDisabled")}
</p>
)}
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
{!tiles ? (
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
+6 -4
View File
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access";
import { effectiveModules } from "@/server/plan";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
@@ -30,17 +31,18 @@ export default async function AppLayout({
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
// Seiten/Actions prüfen serverseitig selbst).
const [moduleRows, brandingSettings] = await Promise.all([
dbForTenant(session.user.tenantId).tenantModule.findMany(),
// L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
const [modules, brandingSettings] = await Promise.all([
effectiveModules(session.user.tenantId),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
where: { tenantId: session.user.tenantId },
select: { accent: true },
}),
]);
const branding = resolveTenantBranding(brandingSettings);
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const navItems = visibleNavItems(NAV_ITEMS, {
disabledModules,
disabledModules: modules.inactive,
lockedFeatures: modules.lockedFeatures,
permissions: session.user.permissions ?? [],
});
const mainNav = navItems.filter((i) => i.section === "main");
+4 -2
View File
@@ -1,7 +1,9 @@
import { requireModule } from "@/server/modules";
import { requirePlanFeature } from "@/server/plan";
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
/** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders");
const session = await requireModule("work_orders");
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
return <>{children}</>;
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
import { PageHead } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { saveLotseSettings } from "@/server/actions/lotse-settings";
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
*/
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
const ctx = await readCtx();
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
@@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
</Link>
</section>
</div>
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
</main>
);
}
+4 -3
View File
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
import { updateTenantSettings } from "@/server/actions/tenant-settings";
import { ShieldCheck, History } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { effectiveModules } from "@/server/plan";
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
@@ -28,9 +29,9 @@ export default async function SettingsPage({
const db = dbForTenant(session.user.tenantId);
const sp = await searchParams;
const [s, moduleRows] = await Promise.all([
const [s, modules] = await Promise.all([
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
db.tenantModule.findMany(),
effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
]);
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
@@ -50,7 +51,7 @@ export default async function SettingsPage({
}
}
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const disabled = modules.inactive;
const v = (x?: string | null) => x ?? "";
return (
+4 -5
View File
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition";
import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number];
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
]);
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
const billingOverviewActive = !billingModule || billingModule.enabled;
const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
const billingTransitions = transitions.filter(
(to) =>
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/>
</div>
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
}
/>
)}
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />}
</div>
+9 -1
View File
@@ -1,7 +1,15 @@
import { requireSession } from "@/server/auth";
import { requireModule } from "@/server/modules";
import { moduleState } from "@/server/plan";
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
/**
* Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx.
* L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard.
*/
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireSession();
if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return <LotseChatUnavailable reason="not_in_plan" />;
await requireModule("lotse");
return <>{children}</>;
}
+7 -18
View File
@@ -1,17 +1,18 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ChevronLeft, CircleSlash } from "lucide-react";
import { ChevronLeft } from "lucide-react";
import type { ChatView } from "@/lib/lotse/chat";
import { isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access";
import { getChatView } from "@/server/services/lotse/chat/conversations";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
import { card } from "@/components/field/ui";
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/**
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
@@ -27,24 +28,11 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
if (!(err instanceof ServiceError)) throw err;
if (err.code === "not_found") notFound();
const reason = (err.details as { reason?: string } | undefined)?.reason;
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
return (
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
return <LotseChatUnavailable reason={code} />;
}
const timeZone = await tenantTimezone(ctx);
const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
return (
<main className="space-y-3 p-4">
{view.workOrder && (
@@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
transcription={transcriptionConfig().configured}
locale={locale}
timeZone={timeZone}
quotaExhausted={quotaExhausted}
/>
</main>
);
+12 -5
View File
@@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
import { isModuleActive } from "@/server/plan";
import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
@@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
try {
const ctx = await fieldPageContext();
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
const [session, approvals, lotseChat, emergency] = await Promise.all([
can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
countPendingTimeEntries(ctx),
canUseLotseChat(ctx),
isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
]);
return {
clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null,
approvals,
lotseChat, // L16
lotseChat, // L16 (L17: inkl. Chat-Platz)
emergency,
};
} catch {
// module "field" disabled or no field permissions: shell without clock/badge
return { clock: null, approvals: 0, lotseChat: false };
return { clock: null, approvals: 0, lotseChat: false, emergency: true };
}
}
@@ -60,7 +67,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
<BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
</div>
);
}
+12 -1
View File
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules";
import { effectiveTier, isModuleInTier } from "@/lib/plans";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
@@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -28,7 +30,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -58,6 +60,9 @@ export default async function AdminTenantPage({
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
const base = `/admin/${tenant.id}`;
// L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
const tierNow = effectiveTier(tenant.tier, tenant.plan);
const tp = await getTranslations("plans");
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true;
@@ -226,6 +231,11 @@ export default async function AdminTenantPage({
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
{/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
{platformSession?.user?.id && (
<PlanAdminCard tenantId={tenant.id} platformAdminId={platformSession.user.id} isFullAdmin={isFullAdmin} base={base} edit={sp.plan === "edit"} done={!!sp.planDone} />
)}
{/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
@@ -342,6 +352,7 @@ export default async function AdminTenantPage({
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
</div>
<div className="flex items-center gap-2">
{!isModuleInTier(tierNow, m.key) && <Pill tone="warn">{tp("notInTier")}</Pill>}
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
+4
View File
@@ -27,6 +27,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const params = await searchParams;
// L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform");
const tp = await getTranslations("plans");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([
@@ -124,6 +125,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead>
<TableHead>{tp("colTier")}</TableHead>
<TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead>
</TableRow>
@@ -140,6 +142,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell>
{/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */}
<TableCell><Pill tone={t.tier === "PROFI" ? "ok" : "mut"}>{tp(`tier.${t.tier}`)}</Pill></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow>
+7 -3
View File
@@ -19,13 +19,17 @@ const ITEMS = [
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
*/
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) {
const GRID_COLS: Record<number, string> = { 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6" };
/** L17 Pakete: `emergency` = Notdienst im Paket enthalten und eingeschaltet; `lotse` = Chat nutzbar (inkl. Platz). */
export function BottomNav({ approvals = 0, lotse = false, emergency = true }: { approvals?: number; lotse?: boolean; emergency?: boolean }) {
const t = useTranslations("field.nav");
const pathname = usePathname();
const items = ITEMS.filter((item) => (lotse || item.key !== "lotse") && (emergency || item.key !== "emergency"));
return (
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}>
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => {
<ul className={cn("mx-auto grid max-w-xl", GRID_COLS[items.length] ?? "grid-cols-5")}>
{items.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return (
+13 -2
View File
@@ -41,6 +41,7 @@ export function LotseChat({
transcription,
locale,
timeZone,
quotaExhausted = false,
}: {
initial: ChatView;
workOrderId: string | null;
@@ -48,6 +49,8 @@ export function LotseChat({
transcription: boolean;
locale: string;
timeZone: string;
/** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */
quotaExhausted?: boolean;
}) {
const t = useTranslations("lotse");
const router = useRouter();
@@ -128,6 +131,7 @@ export function LotseChat({
}
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
const sendLocked = quotaExhausted || error === "quota_exhausted";
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
function notice(m: ChatMessageView): string | null {
@@ -235,7 +239,7 @@ export function LotseChat({
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
{m.content.chips.map((c) => (
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}>
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured || sendLocked} onClick={() => send(c.value)}>
{c.label}
</button>
))}
@@ -270,13 +274,19 @@ export function LotseChat({
{t("chat.offline")}
</p>
)}
{errorText && (
{errorText && error !== "quota_exhausted" && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{errorText}
</p>
)}
{sendLocked ? (
<p className={noticeWarn} role="status">
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("chat.quotaExhausted")}
</p>
) : (
<form
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
onSubmit={(e) => {
@@ -315,6 +325,7 @@ export function LotseChat({
</div>
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
</form>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2, CircleDashed, Gauge, Info, Package, TriangleAlert, XCircle } from "lucide-react";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { setChatSeatAction } from "@/server/actions/lotse/seats";
import { getTenantPlan } from "@/server/plan";
import type { ServiceCtx } from "@/server/services/context";
import { getSeatOverview } from "@/server/services/lotse/chat/seats";
import { getChatUsageForAdmin } from "@/server/services/lotse/chat/usage";
const LEVEL_TONE = { ok: "var(--ok)", warn: "var(--warn)", exhausted: "var(--warn)", over: "var(--risk)" } as const;
/**
* L17 Pakete — /settings/lotse (tenant:manage): package (read-only), Lotse chat seats „x von y vergeben"
* with a switch per user with a field role, monthly usage bar with text, 80 % / overage hints and the
* per-user breakdown. Every change runs through setChatSeatAction (server-side limits + audit).
*/
export async function LotseSeatAdmin({ ctx, seatError, seatSaved }: { ctx: ServiceCtx; seatError?: string; seatSaved?: boolean }) {
const [t, locale, plan] = await Promise.all([getTranslations("plans"), getLocale(), getTenantPlan(ctx.tenantId)]);
const nf = new Intl.NumberFormat(locale);
const profi = plan.tier === "PROFI";
const [seats, usage] = profi ? await Promise.all([getSeatOverview(ctx), getChatUsageForAdmin(ctx)]) : [null, null];
const free = seats ? Math.max(0, seats.purchased - seats.assigned) : 0;
const monthLabel = usage ? new Date(`${usage.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" }) : "";
return (
<section id="seats" aria-labelledby="seats-title" className="shadow-card mt-5 max-w-5xl space-y-5 rounded-xl border bg-card p-5 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="seats-title" className="flex items-center gap-2 font-heading text-sm font-semibold">
<Package className="size-4" aria-hidden /> {t("admin.title")}
</h2>
<p className="text-[12px] text-muted-foreground">{t("admin.sub")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[12.5px] text-muted-foreground">{t("admin.package")}</span>
<Pill tone={profi ? "ok" : "mut"}>{t(`tier.${plan.tier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("admin.trial")}</Pill>}
</div>
</div>
<p className="text-[12px] text-muted-foreground">{t("admin.packageReadOnly")}</p>
{seatSaved && (
<p role="status" className="flex items-center gap-2 rounded-lg border border-[var(--ok)] px-3 py-2 text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {t("admin.saved")}
</p>
)}
{seatError && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")}
</p>
)}
{!profi || !seats || !usage ? (
<p className="flex items-start gap-2 rounded-lg bg-muted p-3">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.basisHint")}
</p>
) : (
<>
{/* Plätze */}
<div className="space-y-2">
<h3 className="font-semibold">{t("admin.seatsTitle")}</h3>
{plan.isTrial ? (
<p className="flex items-start gap-2 text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })}
</p>
) : (
<p className="text-[15px] font-semibold">{t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}</p>
)}
{seats.overbooked && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overbooked", { purchased: seats.purchased })}
</p>
)}
{!plan.isTrial && seats.purchased === 0 && <p className="text-muted-foreground">{t("admin.noSeatsBought")}</p>}
{seats.users.length === 0 ? (
<p className="text-muted-foreground">{t("admin.noFieldUsers")}</p>
) : (
<ul className="divide-y rounded-lg border">
{seats.users.map((u) => {
const canAssign = !u.assigned && free > 0;
return (
<li key={u.id} className="flex flex-wrap items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="font-medium">{u.name}</p>
<p className="truncate text-[12px] text-muted-foreground">{u.email}</p>
</div>
<div className="flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1 text-[12.5px] font-semibold", !u.assigned && "text-muted-foreground")} style={u.assigned ? { color: u.valid ? "var(--ok)" : "var(--warn)" } : undefined}>
{u.assigned ? (u.valid ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />) : <CircleDashed className="size-4" aria-hidden />}
{u.assigned ? (u.valid ? t("admin.seatAssigned") : t("admin.seatOverbooked")) : t("admin.seatNone")}
</span>
<form action={setChatSeatAction}>
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="assigned" value={u.assigned ? "0" : "1"} />
<button
type="submit"
role="switch"
aria-checked={u.assigned}
aria-label={u.assigned ? t("admin.revokeLabel", { name: u.name }) : t("admin.assignLabel", { name: u.name })}
disabled={!u.assigned && !canAssign}
className={cn(
"inline-flex min-h-11 min-w-28 items-center justify-center rounded-lg border px-3 text-[13px] font-semibold disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
u.assigned ? "border-[var(--ui-primary)] text-[var(--ui-primary)]" : "hover:bg-muted",
)}
>
{u.assigned ? t("admin.revoke") : t("admin.assign")}
</button>
</form>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Monatsverbrauch */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 font-semibold">
<Gauge className="size-4" aria-hidden /> {t("admin.usageTitle", { month: monthLabel })}
</h3>
<div
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={usage.percent}
aria-label={t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}
className="h-3 w-full overflow-hidden rounded-full bg-muted"
>
<div className="h-full rounded-full" style={{ width: `${usage.percent}%`, background: LEVEL_TONE[usage.level] }} />
</div>
<p className="text-[15px] font-semibold">{t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}</p>
<p className="text-[12px] text-muted-foreground">{t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}</p>
{usage.level === "warn" && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.warn80", { percent: usage.percent })}
</p>
)}
{(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.hardLimitReached")}
</p>
)}
{usage.level === "exhausted" && !usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.overage > 0 && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.perUser.length > 0 && (
<table className="mt-2 w-full max-w-md text-left">
<caption className="sr-only">{t("admin.perUserCaption")}</caption>
<thead>
<tr className="text-[12px] text-muted-foreground">
<th scope="col" className="py-1 font-medium">{t("admin.perUserName")}</th>
<th scope="col" className="py-1 text-right font-medium">{t("admin.perUserChats")}</th>
</tr>
</thead>
<tbody className="divide-y">
{usage.perUser.map((u) => (
<tr key={u.userId}>
<td className="py-1.5">{u.name}</td>
<td className="py-1.5 text-right tabular-nums">{nf.format(u.count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CircleSlash } from "lucide-react";
import { card } from "@/components/field/ui";
/** „Lotse-Chat nicht verfügbar" (L16; L17: kein Platz / nicht im Paket) — text + icon, back to /m. */
export async function LotseChatUnavailable({ reason }: { reason: string }) {
const t = await getTranslations("lotse");
return (
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { updateTenantPlanAction } from "@/server/actions/plans-platform";
import type { ChatUsage } from "@/server/services/lotse/chat/usage";
import { getTenantPlanOverview, MAX_LOTSE_CHAT_SEATS } from "@/server/services/plans/platform";
import { PlanForm } from "./plan-form";
/**
* L17 Pakete — card "Paket & Lotse-Chat" on the platform tenant detail page: tier, seats assigned /
* purchased, chats of the current and previous month, overage and the resulting packs of 100 (basis for
* the invoice). Full admins change tier/seats/hard limit in a confirmation popup (?plan=edit).
*/
export async function PlanAdminCard({ tenantId, platformAdminId, isFullAdmin, base, edit, done }: { tenantId: string; platformAdminId: string; isFullAdmin: boolean; base: string; edit: boolean; done: boolean }) {
const [t, locale, overview] = await Promise.all([getTranslations("plans.platform"), getLocale(), getTenantPlanOverview({ platformAdminId }, tenantId)]);
const tt = await getTranslations("plans");
const nf = new Intl.NumberFormat(locale);
const { plan, seatsAssigned, current, previous } = overview;
const monthName = (u: ChatUsage) => new Date(`${u.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
const usageRows = [current, previous].map((u) => (
<div key={u.monthKey} className="rounded-lg border bg-muted/40 px-3 py-2" data-plan-usage={u.monthKey}>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}</p>
<p className="text-[13.5px] font-semibold">{t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}</p>
<p className="text-[12.5px]">
{u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")}
</p>
</div>
));
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-plan-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("title")}</p>
{done && <p role="status" className="mb-3 rounded-lg border border-[var(--ok)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t("saved")}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("tier")}</dt>
<dd className="flex flex-wrap justify-end gap-1.5">
<Pill tone={plan.storedTier === "PROFI" ? "ok" : "mut"}>{tt(`tier.${plan.storedTier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("trialProfi")}</Pill>}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("seats")}</dt>
<dd>{t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("hardLimit")}</dt>
<dd>{plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}</dd>
</div>
</dl>
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}</p>
<div className="mt-3 space-y-2">{usageRows}</div>
{isFullAdmin && (
<Link href={`${base}?plan=edit`} scroll={false} className="mt-3 block">
<Button variant="outline" size="sm" className="min-h-11 w-full justify-center">
{t("edit")}
</Button>
</Link>
)}
{isFullAdmin && edit && (
<Modal title={t("editTitle")} closeHref={base} closeLabel={t("close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t("editText")}</p>
<PlanForm
action={updateTenantPlanAction.bind(null, tenantId)}
tier={plan.storedTier}
seats={plan.lotseChatSeats}
hardLimit={plan.lotseChatHardLimit}
maxSeats={MAX_LOTSE_CHAT_SEATS}
closeHref={base}
/>
</div>
</Modal>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
import type { PlatformPlanState } from "@/server/actions/plans-platform";
/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
export function PlanForm({
action,
tier,
seats,
hardLimit,
maxSeats,
closeHref,
}: {
action: (prev: PlatformPlanState, fd: FormData) => Promise<PlatformPlanState>;
tier: TenantTier;
seats: number;
hardLimit: boolean;
maxSeats: number;
closeHref: string;
}) {
const t = useTranslations("plans.platform");
const tt = useTranslations("plans");
const [state, formAction, pending] = useActionState<PlatformPlanState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-4">
<fieldset>
<legend className="text-sm font-semibold">{t("tier")}</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{TENANT_TIERS.map((v) => (
<label key={v} className="flex min-h-11 cursor-pointer items-start gap-2 rounded-lg border p-3 text-sm has-[:checked]:border-[var(--ui-primary)] has-[:checked]:font-semibold">
<input type="radio" name="tier" value={v} defaultChecked={tier === v} className="mt-0.5 size-4" />
<span>
<span className="block">{tt(`tier.${v}`)}</span>
<span className="block text-[12px] font-normal text-muted-foreground">{t(`tierHint.${v}`)}</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("downgradeHint")}</p>
</fieldset>
<div className="max-w-xs">
<Label htmlFor="plan-seats">{t("seatsInput")}</Label>
<Input id="plan-seats" name="lotseChatSeats" type="number" inputMode="numeric" min={0} max={maxSeats} step={1} required defaultValue={seats} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("seatsHint")}</p>
</div>
<label className="flex min-h-11 items-start gap-2 text-sm">
<input type="checkbox" name="lotseChatHardLimit" defaultChecked={hardLimit} className="mt-0.5 size-5" />
<span>
<span className="block font-semibold">{t("hardLimitInput")}</span>
<span className="block text-[12px] text-muted-foreground">{t("hardLimitHint")}</span>
</span>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
{state.status === "error" && (
<p role="alert" className="rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
{t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>
{pending ? t("submitting") : t("submit")}
</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>
{t("close")}
</Button>
</div>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { getTranslations } from "next-intl/server";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* L17 Pakete: quiet hint "Im Paket Profi enthalten" instead of an error page — shown when a page of a
* module/feature outside the tenant's package tier was opened (redirect `/dashboard?module=profi`).
* Text + icon, colours only via tokens.
*/
export async function ProfiHint({ className, compact = false }: { className?: string; compact?: boolean }) {
const t = await getTranslations("plans");
return (
<div role="status" className={cn("mb-4 flex items-start gap-3 rounded-lg border bg-card px-4 py-3 text-sm", className)}>
<Sparkles className="mt-0.5 size-4.5 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div>
<p className="font-semibold">{t("profiHint.title")}</p>
{!compact && <p className="text-muted-foreground">{t("profiHint.text")}</p>}
</div>
</div>
);
}
+4
View File
@@ -175,6 +175,10 @@ export const LOTSE_CHAT_ERROR_CODES = [
"tampered",
"blocked",
"offline",
// L17 Pakete
"not_in_plan",
"no_seat",
"quota_exhausted",
] as const;
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
+13 -5
View File
@@ -23,6 +23,7 @@ import {
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
import type { PlanFeature } from "@/lib/plans";
import type { Permission } from "@/server/rbac";
/**
@@ -47,14 +48,16 @@ export interface NavItem {
sub?: boolean;
/** L13: active only on exactly this path (not on sub paths) */
exact?: boolean;
/** L17 Pakete: feature without own module that the package tier must include (planning) */
feature?: PlanFeature;
}
export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{
href: "/work-orders",
label: "workOrders",
@@ -81,14 +84,19 @@ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
];
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
/**
* Filtert die Navigation nach aktiven Modulen und Rechten der Session. `disabledModules` = effektiv
* inaktive Module (L17: abgeschaltet ODER nicht in der Paketstufe), `lockedFeatures` = nicht enthaltene
* Paket-Features (Planung).
*/
export function visibleNavItems(
items: readonly NavItem[],
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[] },
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[]; lockedFeatures?: ReadonlySet<string> },
): NavItem[] {
return items.filter(
(item) =>
(!item.module || !opts.disabledModules.has(item.module)) &&
(!item.feature || !opts.lockedFeatures?.has(item.feature)) &&
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
);
}
+3
View File
@@ -333,6 +333,9 @@ export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError
case "not_found":
return "notFound";
case "forbidden":
// L17 Pakete: op of a module that is not part of the package / switched off (services/sync/apply.ts)
if (e.lastError?.message?.startsWith("not_in_plan")) return "notInPlan";
if (e.lastError?.message?.startsWith("module_disabled")) return "moduleDisabled";
return "forbidden";
case "blocked":
return "blocked";
+122
View File
@@ -0,0 +1,122 @@
import type { ModuleKey } from "@/lib/modules";
/**
* L17 Pakete — central, client-safe rules of the two packages (pure data + functions, no I/O).
*
* - `BASIS`: everything without AI and without planning: work orders, customers, sites, teams, mobile
* app/offline, reports + signature + PDF mail, time tracking incl. approval, documents, notifications.
* - `PROFI`: additionally planning (board, live situation, capacity, recommendations, delay hints),
* billing overview (`billing`), emergency service (`emergency`), order import (`imports`) and the
* Lotse (`lotse`: report draft, completeness check, voice note summary, Lotse chat).
*
* The Lotse chat is additionally sold per user (seats): 150 chats per assigned seat and calendar month
* (tenant timezone), shared by the whole tenant. Beyond the quota usage keeps counting (overage, billed
* in packs of 100) unless the operator set a hard limit. A trial tenant is always PROFI without seat
* check; its quota is `eligible users × 150`.
*
* Server enforcement lives in src/server/plan.ts (reads Tenant.tier/plan) and is applied in
* requireModule / assertModuleEnabled / requireApiContext / sync / navigation / isLotseEnabled.
*/
export const TENANT_TIERS = ["BASIS", "PROFI"] as const;
export type TenantTier = (typeof TENANT_TIERS)[number];
/** Default tier of new and existing tenants (migration default). */
export const DEFAULT_TENANT_TIER: TenantTier = "PROFI";
/** Modules that exist only in PROFI. Every other module is part of both tiers. */
export const PROFI_ONLY_MODULES = ["billing", "emergency", "imports", "lotse"] as const satisfies readonly ModuleKey[];
/**
* Features that are no module of their own: `planning` lives inside the `work_orders` module (L13),
* `lotse_chat` inside `lotse` (L16, additionally needs a seat).
*/
export const PLAN_FEATURES = ["planning", "lotse_chat"] as const;
export type PlanFeature = (typeof PLAN_FEATURES)[number];
const PROFI_ONLY_FEATURES: readonly PlanFeature[] = ["planning", "lotse_chat"];
export const LOTSE_CHAT_CHATS_PER_SEAT = 150;
export const LOTSE_CHAT_OVERAGE_PACK = 100;
/** From this share of the quota the tenant admin sees a warning. */
export const LOTSE_CHAT_WARN_RATIO = 0.8;
export function isTenantTier(value: unknown): value is TenantTier {
return typeof value === "string" && (TENANT_TIERS as readonly string[]).includes(value);
}
/** Effective tier: a trial always runs as PROFI (full functionality during the test). */
export function effectiveTier(tier: TenantTier | string | null | undefined, plan?: string | null): TenantTier {
if (plan === "TRIAL") return "PROFI";
return isTenantTier(tier) ? tier : DEFAULT_TENANT_TIER;
}
export function isModuleInTier(tier: TenantTier, moduleKey: string): boolean {
if (tier === "PROFI") return true;
return !(PROFI_ONLY_MODULES as readonly string[]).includes(moduleKey);
}
export function isFeatureInTier(tier: TenantTier, feature: PlanFeature): boolean {
if (tier === "PROFI") return true;
return !PROFI_ONLY_FEATURES.includes(feature);
}
/** Modules that the tier does not include (used for navigation/UI). */
export function modulesLockedByTier(tier: TenantTier): string[] {
return tier === "PROFI" ? [] : [...PROFI_ONLY_MODULES];
}
/** Monthly quota of the tenant: assigned (and paid) seats × 150. */
export function chatQuota(seats: number): number {
return Math.max(0, Math.floor(seats)) * LOTSE_CHAT_CHATS_PER_SEAT;
}
/** Chats above the quota. */
export function chatOverage(used: number, quota: number): number {
return Math.max(0, used - quota);
}
/** Overage packs of 100 (started packs count). */
export function overagePacks(overage: number): number {
return overage <= 0 ? 0 : Math.ceil(overage / LOTSE_CHAT_OVERAGE_PACK);
}
export type UsageLevel = "ok" | "warn" | "exhausted" | "over";
/**
* Level of the monthly usage: `warn` from 80 %, `exhausted` = quota reached (not above),
* `over` = overage. Without quota: any use counts as overage.
*/
export function usageLevel(used: number, quota: number): UsageLevel {
if (quota <= 0) return used > 0 ? "over" : "ok";
if (used > quota) return "over";
if (used === quota) return "exhausted";
return used >= quota * LOTSE_CHAT_WARN_RATIO ? "warn" : "ok";
}
/** Share of the quota in percent (0–100 for the bar; overage caps at 100). */
export function usagePercent(used: number, quota: number): number {
if (quota <= 0) return used > 0 ? 100 : 0;
return Math.min(100, Math.round((used / quota) * 100));
}
/** Hard limit reached → no further chat messages. */
export function isQuotaBlocked(used: number, quota: number, hardLimit: boolean): boolean {
return hardLimit && used >= quota;
}
/** Month key `YYYY-MM` of a day key `YYYY-MM-DD`. */
export function monthKeyOfDay(dayKey: string): string {
return dayKey.slice(0, 7);
}
/** Previous month key of `YYYY-MM`. */
export function previousMonthKey(monthKey: string): string {
const [y, m] = monthKey.split("-").map(Number);
return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
}
/** Next month key of `YYYY-MM`. */
export function nextMonthKey(monthKey: string): string {
const [y, m] = monthKey.split("-").map(Number);
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function chatErrorState(err: unknown, view?: ChatView): LotseChatActionSt
const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code;
return { status: "error", code, at, ...withView };
}
if (err instanceof ModuleDisabledError) return { status: "error", code: "disabled", at, ...withView };
if (err instanceof ModuleDisabledError) return { status: "error", code: err.reason === "not_in_plan" ? "not_in_plan" : "disabled", at, ...withView };
if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView };
if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView };
console.error("[actions/lotse/chat]", err);
+40
View File
@@ -0,0 +1,40 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ModuleDisabledError } from "@/server/modules";
import { ForbiddenError } from "@/server/rbac";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { setChatSeat } from "@/server/services/lotse/chat/seats";
/**
* L17 Pakete — /settings/lotse: assign / revoke a Lotse chat seat (tenant admin, `tenant:manage`).
* moduleGuard("lotse"): only while the Lotse is part of the package and switched on; the service checks
* eligibility, the purchased number (serialised) and writes the audit.
*/
const guard = moduleGuard("lotse");
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return (err.details as { reason?: string } | undefined)?.reason ?? err.code;
if (err instanceof ModuleDisabledError) return err.reason;
if (err instanceof ForbiddenError) return "forbidden";
return "failed";
}
/** Form fields: userId, assigned ("1" = assign, otherwise revoke). */
export async function setChatSeatAction(fd: FormData): Promise<void> {
let target = "/settings/lotse?seat=saved#seats";
try {
const ctx = ctxFromGuard(await guard("tenant:manage"));
await setChatSeat(ctx, { userId: String(fd.get("userId") ?? ""), assigned: fd.get("assigned") === "1" });
revalidatePath("/settings/lotse");
revalidatePath("/m", "layout");
} catch (err) {
const code = errorCode(err);
if (code === "failed") console.error("[actions/lotse/seats]", err);
target = `/settings/lotse?seatError=${encodeURIComponent(code)}#seats`;
}
redirect(target);
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { ServiceError } from "@/server/services/context";
import { updateTenantPlan } from "@/server/services/plans/platform";
/**
* L17 Pakete — operator action on the platform tenant detail page: tier, Lotse chat seats, hard limit.
* EXEMPT from module gating; authorisation via the separate platform session (full admins only). The
* service re-checks the actor against the PlatformAdmin store and writes the platform audit.
*/
export type PlatformPlanState = { status: "idle" } | { status: "error"; code: string };
export async function updateTenantPlanAction(tenantId: string, _prev: PlatformPlanState, fd: FormData): Promise<PlatformPlanState> {
const { admin } = await requirePlatformFullAdmin();
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
try {
await updateTenantPlan(
{ platformAdminId: admin.id },
tenantId,
{
tier: String(fd.get("tier") ?? "") as "BASIS" | "PROFI",
lotseChatSeats: String(fd.get("lotseChatSeats") ?? "").trim() || "0",
lotseChatHardLimit: fd.get("lotseChatHardLimit") === "on",
},
);
} catch (err) {
return { status: "error", code: err instanceof ServiceError ? err.message : "failed" };
}
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
// the tenant shell renders navigation/module gates per request from the plan
revalidatePath("/", "layout");
redirect(`/admin/${tenantId}?planDone=1`);
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
import { PROFI_ONLY_MESSAGE } from "@/server/plan";
/**
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
@@ -73,7 +74,10 @@ export function toErrorResponse(err: unknown): Response {
);
}
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
if (err instanceof ModuleDisabledError) {
// L17 Pakete: Modul nicht in der Paketstufe → Klartext für Clients
return err.reason === "not_in_plan" ? errorResponse("forbidden", "not included in plan", { reason: "not_in_plan", module: err.moduleKey, message: PROFI_ONLY_MESSAGE }) : errorResponse("forbidden", "module disabled");
}
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
console.error("[api] unhandled error", err);
return errorResponse("internal", "internal error");
+2
View File
@@ -81,6 +81,8 @@ export const TENANT_MODELS: readonly string[] = [
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
// L17 Pakete: vergebene Lotse-Chat-Plätze
"LotseChatSeat",
];
/**
+2
View File
@@ -132,6 +132,8 @@ const TENANT_MODELS = new Set<string>([
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
// L17 Pakete: vergebene Lotse-Chat-Plätze
"LotseChatSeat",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
+3
View File
@@ -61,6 +61,9 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "LotseActionProposal", field: "confirmedById" },
// L15 Testphase: Datenexport
{ model: "TenantExport", field: "requestedById" },
// L17 Pakete: Lotse-Chat-Platz (Inhaber + vergebende Person)
{ model: "LotseChatSeat", field: "userId" },
{ model: "LotseChatSeat", field: "assignedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export.
];
+16 -12
View File
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { moduleState, PROFI_HINT_PATH, PROFI_ONLY_MESSAGE } from "@/server/plan";
/**
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
@@ -12,17 +12,21 @@ import { writeAuditLog } from "@/server/audit";
*/
export async function requireModule(moduleKey: string) {
const session = await requireSession();
const row = await dbForTenant(session.user.tenantId).tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: session.user.tenantId, moduleKey } },
});
if (row && !row.enabled) redirect("/dashboard?module=disabled");
// L17 Pakete: Stufe + TenantModule zentral (src/server/plan.ts); nicht im Paket → ruhiger Hinweis
const state = await moduleState(session.user.tenantId, moduleKey);
if (state === "not_in_plan") redirect(PROFI_HINT_PATH);
if (state === "disabled") redirect("/dashboard?module=disabled");
return session;
}
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
export class ModuleDisabledError extends Error {
constructor(public readonly moduleKey: string) {
super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
constructor(
public readonly moduleKey: string,
/** L17 Pakete: `not_in_plan` = Modul gehört nicht zur Paketstufe des Mandanten (Basis). */
public readonly reason: "disabled" | "not_in_plan" = "disabled",
) {
super(reason === "not_in_plan" ? `Modul „${moduleKey}": ${PROFI_ONLY_MESSAGE}` : `Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
this.name = "ModuleDisabledError";
}
}
@@ -37,17 +41,17 @@ export class ModuleDisabledError extends Error {
*/
export async function assertModuleEnabled(session: Session, moduleKey: string) {
const tenantId = session.user.tenantId;
const row = await dbForTenant(tenantId).tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
});
if (row && !row.enabled) {
// L17 Pakete: effektive Freischaltung = Stufe erlaubt UND TenantModule aktiv (src/server/plan.ts)
const state = await moduleState(tenantId, moduleKey);
if (state !== "enabled") {
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "denied",
entity: "module",
entityId: moduleKey,
...(state === "not_in_plan" ? { after: { reason: "not_in_plan" } } : {}),
});
throw new ModuleDisabledError(moduleKey);
throw new ModuleDisabledError(moduleKey, state === "not_in_plan" ? "not_in_plan" : "disabled");
}
}
+111
View File
@@ -0,0 +1,111 @@
import { redirect } from "next/navigation";
import { dbForTenant, prisma } from "@/server/db";
import { ServiceError } from "@/server/services/context";
import { MODULE_KEYS } from "@/lib/modules";
import { effectiveTier, isFeatureInTier, isModuleInTier, PLAN_FEATURES, type PlanFeature, type TenantTier } from "@/lib/plans";
/**
* L17 Pakete — the ONE server-side place that combines the package tier (Tenant.tier, trial = PROFI)
* with the tenant module switches (TenantModule). Effective module = tier includes it AND the module
* is switched on (missing TenantModule row = on). Every gate uses these functions:
* - requireModule / assertModuleEnabled (src/server/modules.ts) → layouts, moduleGuard, requireApiContext
* - sync apply (services/sync/apply.ts, ops of a locked module → rejected with plain text)
* - navigation (app layout, mobile shell), isLotseEnabled, planningAccess.
* `Tenant` is a platform table (no tenant_id/RLS) and is read with the owner client by id; only the
* operator changes tier/seats (services/plans/platform.ts).
*/
export type TenantPlanInfo = {
/** Effective tier (trial → PROFI). */
tier: TenantTier;
/** Stored tier (what the operator set). */
storedTier: TenantTier;
isTrial: boolean;
lotseChatSeats: number;
lotseChatHardLimit: boolean;
};
export const PLAN_SELECT = { tier: true, plan: true, lotseChatSeats: true, lotseChatHardLimit: true } as const;
export function toPlanInfo(row: { tier: TenantTier; plan: string; lotseChatSeats: number; lotseChatHardLimit: boolean } | null): TenantPlanInfo {
if (!row) return { tier: "PROFI", storedTier: "PROFI", isTrial: false, lotseChatSeats: 0, lotseChatHardLimit: false };
return {
tier: effectiveTier(row.tier, row.plan),
storedTier: row.tier,
isTrial: row.plan === "TRIAL",
lotseChatSeats: row.lotseChatSeats,
lotseChatHardLimit: row.lotseChatHardLimit,
};
}
export async function getTenantPlan(tenantId: string): Promise<TenantPlanInfo> {
return toPlanInfo(await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT }));
}
/** Plain-language hint for API clients / sync results (UI texts come from messages `plans.*`). */
export const PROFI_ONLY_MESSAGE = "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet.";
export type ModuleState = "enabled" | "disabled" | "not_in_plan";
/** Effective state of one module: tier first, then the tenant switch. */
export async function moduleState(tenantId: string, moduleKey: string): Promise<ModuleState> {
const plan = await getTenantPlan(tenantId);
if (!isModuleInTier(plan.tier, moduleKey)) return "not_in_plan";
const row = await dbForTenant(tenantId).tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
select: { enabled: true },
});
return row && !row.enabled ? "disabled" : "enabled";
}
export async function isModuleActive(tenantId: string, moduleKey: string): Promise<boolean> {
return (await moduleState(tenantId, moduleKey)) === "enabled";
}
export type EffectiveModules = {
tier: TenantTier;
isTrial: boolean;
/** Modules switched off by the tenant/operator OR not included in the tier. */
inactive: Set<string>;
/** Subset of `inactive`: locked only by the tier ("Im Paket Profi enthalten"). */
notInPlan: Set<string>;
/** Features (planning, lotse_chat) not included in the tier. */
lockedFeatures: Set<PlanFeature>;
};
/** All module states in two queries (navigation, settings overview). */
export async function effectiveModules(tenantId: string): Promise<EffectiveModules> {
const [plan, rows] = await Promise.all([getTenantPlan(tenantId), dbForTenant(tenantId).tenantModule.findMany({ select: { moduleKey: true, enabled: true } })]);
const notInPlan = new Set(MODULE_KEYS.filter((k) => !isModuleInTier(plan.tier, k)));
const inactive = new Set<string>([...notInPlan, ...rows.filter((r) => !r.enabled).map((r) => r.moduleKey)]);
const lockedFeatures = new Set(PLAN_FEATURES.filter((f) => !isFeatureInTier(plan.tier, f)));
return { tier: plan.tier, isTrial: plan.isTrial, inactive, notInPlan, lockedFeatures };
}
export async function isPlanFeatureAvailable(tenantId: string, feature: PlanFeature): Promise<boolean> {
return isFeatureInTier((await getTenantPlan(tenantId)).tier, feature);
}
/** `forbidden` with reason `not_in_plan` — callers that hide sections on `forbidden` keep working. */
export class PlanFeatureError extends ServiceError {
constructor(public readonly feature: string) {
super("forbidden", "not_in_plan", { reason: "not_in_plan", feature, message: PROFI_ONLY_MESSAGE });
this.name = "PlanFeatureError";
}
}
export function isNotInPlanError(err: unknown): boolean {
return err instanceof ServiceError && err.message === "not_in_plan";
}
export async function assertPlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
if (!(await isPlanFeatureAvailable(tenantId, feature))) throw new PlanFeatureError(feature);
}
/** Dashboard hint target for pages of a locked module/feature (quiet hint instead of an error page). */
export const PROFI_HINT_PATH = "/dashboard?module=profi";
/** Layout gate for a feature without own module (planning). */
export async function requirePlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
if (!(await isPlanFeatureAvailable(tenantId, feature))) redirect(PROFI_HINT_PATH);
}
+45 -10
View File
@@ -1,27 +1,62 @@
import { isFeatureInTier } from "@/lib/plans";
import { getTenantPlan } from "@/server/plan";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { isLotseEnabled } from "../settings";
import { hasValidChatSeat } from "./seats";
import { getChatUsage } from "./usage";
/**
* Gate of the Lotse chat for technicians (lane L16): `lotse:use` + `field:execute`, Lotse module on
* (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure" (TenantSettings.lotseChatEnabled,
* default on). Switched off → `blocked` (reason disabled / chat_disabled).
* Gate of the Lotse chat for technicians (lane L16, extended by L17 Pakete) — the ONE place for:
* - permissions `lotse:use` + `field:execute`,
* - package tier PROFI (Basis → `blocked not_in_plan`; trial = PROFI),
* - Lotse module on (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure",
* - a chat seat of the user (`blocked no_seat`; trial: no seat check),
* - when SENDING: the monthly quota with hard limit (`blocked quota_exhausted`; without hard limit the
* usage keeps counting as overage). The token budget (budget.ts) stays as a separate safety net.
*/
export type LotseChatBlockReason = "not_in_plan" | "disabled" | "chat_disabled" | "no_seat";
export async function isLotseChatEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
if (!(await isLotseEnabled(ctx))) return false;
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
return s?.lotseChatEnabled ?? true;
}
/** UI convenience: may the user open the chat at all (permissions + switches)? Never a security check. */
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> {
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await isLotseChatEnabled(ctx));
/** Tenant- and user-level gate without permissions and quota; null = open. */
export async function lotseChatBlockReason(ctx: ServiceCtx): Promise<LotseChatBlockReason | null> {
const plan = await getTenantPlan(ctx.tenantId);
if (!isFeatureInTier(plan.tier, "lotse_chat")) return "not_in_plan";
if (!(await isLotseEnabled(ctx))) return "disabled";
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
if (s && !s.lotseChatEnabled) return "chat_disabled";
if (!plan.isTrial && !(await hasValidChatSeat(ctx, ctx.userId, plan.lotseChatSeats))) return "no_seat";
return null;
}
export async function assertLotseChatUsable(ctx: ServiceCtx): Promise<void> {
/** UI convenience: may the user open the chat at all (permissions + switches + seat)? Never a security check. */
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> {
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await lotseChatBlockReason(ctx)) === null;
}
const BLOCK_MESSAGES: Record<LotseChatBlockReason, string> = {
not_in_plan: "lotse chat not included in plan",
disabled: "lotse disabled for tenant",
chat_disabled: "lotse chat disabled for tenant",
no_seat: "no lotse chat seat",
};
/** Hard limit reached for the current month (tenant timezone)? */
export async function isChatQuotaBlocked(ctx: ServiceCtx, now: Date = new Date()): Promise<boolean> {
return (await getChatUsage(ctx, { now, perUser: false })).blocked;
}
export async function assertLotseChatUsable(ctx: ServiceCtx, opts: { send?: boolean; now?: Date } = {}): Promise<void> {
assertCan(ctx, "lotse:use");
assertCan(ctx, "field:execute");
if (!(await isLotseEnabled(ctx))) throw new ServiceError("blocked", "lotse disabled for tenant", { reason: "disabled" });
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
if (s && !s.lotseChatEnabled) throw new ServiceError("blocked", "lotse chat disabled for tenant", { reason: "chat_disabled" });
const reason = await lotseChatBlockReason(ctx);
if (reason) throw new ServiceError("blocked", BLOCK_MESSAGES[reason], { reason });
if (opts.send && (await isChatQuotaBlocked(ctx, opts.now))) {
throw new ServiceError("blocked", "lotse chat quota exhausted", { reason: "quota_exhausted" });
}
}
+3 -2
View File
@@ -84,9 +84,10 @@ async function historyTurns(ctx: ServiceCtx, conversationId: string, env: Minimi
}
export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise<ChatView> {
await assertLotseChatUsable(ctx);
const input = sendMessageSchema.parse(raw);
const now = (deps.now ?? (() => new Date()))();
// L17 Pakete: seat + monthly quota (hard limit → blocked quota_exhausted) before anything is stored
await assertLotseChatUsable(ctx, { send: true, now });
const input = sendMessageSchema.parse(raw);
const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!;
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
await assertTokenBudget(ctx, now);
+130
View File
@@ -0,0 +1,130 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { getTenantPlan } from "@/server/plan";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* L17 Pakete — Lotse chat seats. The operator sets the number of purchased seats per tenant
* (Tenant.lotseChatSeats, platform only); the tenant admin (`tenant:manage`) assigns them to users with
* a field role (`lotse:use` + `field:execute`). Assigning beyond the purchased number → `invalid`.
*
* If the operator reduces the number below the assigned seats, the rows stay (nothing is deleted),
* but only the OLDEST `lotseChatSeats` assignments are valid (createdAt, id) — the admin sees the
* overbooking and revokes seats. Switching the tenant to Basis keeps all rows.
*/
type SeatCtx = Pick<ServiceCtx, "db" | "tenantId">;
const ELIGIBLE_PERMISSIONS = ["lotse:use", "field:execute"] as const;
/** Where clause: active members whose roles grant BOTH chat permissions (DB-authoritative). */
export function eligibleUserWhere() {
return {
status: "ACTIVE" as const,
AND: ELIGIBLE_PERMISSIONS.map((key) => ({ userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } })),
};
}
export async function listEligibleUsers(ctx: SeatCtx): Promise<{ id: string; name: string; email: string }[]> {
return ctx.db.user.findMany({ where: eligibleUserWhere(), select: { id: true, name: true, email: true }, orderBy: [{ name: "asc" }, { id: "asc" }] });
}
export async function countEligibleUsers(ctx: SeatCtx): Promise<number> {
return ctx.db.user.count({ where: eligibleUserWhere() });
}
/** User ids holding a VALID seat (the oldest `purchased` assignments). */
export async function validSeatUserIds(ctx: SeatCtx, purchased: number): Promise<Set<string>> {
if (purchased <= 0) return new Set();
const rows = await ctx.db.lotseChatSeat.findMany({ orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: purchased, select: { userId: true } });
return new Set(rows.map((r) => r.userId));
}
export async function hasValidChatSeat(ctx: SeatCtx, userId: string, purchased: number): Promise<boolean> {
return (await validSeatUserIds(ctx, purchased)).has(userId);
}
/** Assigned seats that count (capped at the purchased number) — basis of the monthly quota. */
export async function countAssignedSeats(ctx: SeatCtx, purchased: number): Promise<{ assigned: number; counted: number }> {
const assigned = await ctx.db.lotseChatSeat.count();
return { assigned, counted: Math.min(assigned, Math.max(0, purchased)) };
}
export type SeatOverview = {
purchased: number;
assigned: number;
/** more rows than purchased seats (operator reduced the number) */
overbooked: boolean;
users: { id: string; name: string; email: string; assigned: boolean; valid: boolean; assignedAt: string | null }[];
};
/** Admin view: purchased/assigned seats + all users with a field role and their seat state. */
export async function getSeatOverview(ctx: ServiceCtx): Promise<SeatOverview> {
assertCan(ctx, "tenant:manage");
const plan = await getTenantPlan(ctx.tenantId);
const [eligible, rows, valid] = await Promise.all([
listEligibleUsers(ctx),
ctx.db.lotseChatSeat.findMany({ select: { userId: true, createdAt: true } }),
validSeatUserIds(ctx, plan.lotseChatSeats),
]);
const seatOf = new Map(rows.map((r) => [r.userId, r.createdAt]));
const users = eligible.map((u) => ({ ...u, assigned: seatOf.has(u.id), valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null }));
// seats of users who lost the field role / were deactivated still occupy a seat → listed too
const eligibleIds = new Set(eligible.map((u) => u.id));
const orphanIds = rows.map((r) => r.userId).filter((id) => !eligibleIds.has(id));
if (orphanIds.length) {
const orphans = await ctx.db.user.findMany({ where: { id: { in: orphanIds } }, select: { id: true, name: true, email: true } });
for (const u of orphans) users.push({ ...u, assigned: true, valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null });
}
return { purchased: plan.lotseChatSeats, assigned: rows.length, overbooked: rows.length > plan.lotseChatSeats, users };
}
export const seatAssignmentSchema = z.object({ userId: z.string().trim().min(1).max(64), assigned: z.boolean() });
export type SeatAssignmentInput = z.input<typeof seatAssignmentSchema>;
/**
* Assign or revoke a seat (`tenant:manage`). Assigning: the user must be an active member with a field
* role (else `invalid not_eligible`) and a purchased seat must be free (else `invalid no_seats_left`).
* Serialised per tenant by a row lock on the tenant settings (parallel admins cannot exceed the number).
* Idempotent: assigning an assigned / revoking a free seat changes nothing. Audit before/after.
*/
export async function setChatSeat(ctx: ServiceCtx, raw: SeatAssignmentInput): Promise<{ userId: string; assigned: boolean; changed: boolean }> {
assertCan(ctx, "tenant:manage");
const input = seatAssignmentSchema.parse(raw);
const plan = await getTenantPlan(ctx.tenantId);
if (input.assigned && plan.tier !== "PROFI") throw new ServiceError("blocked", "not_in_plan", { reason: "not_in_plan" });
const result = await inTransaction(ctx, async (tx) => {
// row lock: serialises seat changes of this tenant until commit
await tx.db.tenantSettings.updateMany({ data: { updatedAt: new Date() } });
const existing = await tx.db.lotseChatSeat.findFirst({ where: { userId: input.userId }, select: { id: true, userId: true, createdAt: true, assignedById: true } });
if (!input.assigned) {
if (!existing) return { changed: false, before: null, after: null };
await tx.db.lotseChatSeat.delete({ where: { id: existing.id } });
return { changed: true, before: { userId: existing.userId, assignedById: existing.assignedById }, after: null };
}
if (existing) return { changed: false, before: null, after: null };
const user = await tx.db.user.findFirst({ where: { AND: [{ id: input.userId }, eligibleUserWhere()] }, select: { id: true } });
if (!user) {
// unknown id, other tenant, inactive or without field role — never reveal which
throw new ServiceError("invalid", "not_eligible", { reason: "not_eligible" });
}
const assigned = await tx.db.lotseChatSeat.count();
if (assigned >= plan.lotseChatSeats) throw new ServiceError("invalid", "no_seats_left", { reason: "no_seats_left", purchased: plan.lotseChatSeats, assigned });
const row = await tx.db.lotseChatSeat.create({ data: { tenantId: tx.tenantId, userId: user.id, assignedById: ctx.userId }, select: { id: true, userId: true, assignedById: true } });
return { changed: true, before: null, after: { userId: row.userId, assignedById: row.assignedById } };
});
if (result.changed) {
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: input.assigned ? "create" : "delete",
entity: "lotse_chat_seat",
entityId: input.userId,
before: result.before ?? undefined,
after: result.after ?? undefined,
});
}
return { userId: input.userId, assigned: input.assigned, changed: result.changed };
}
+1 -1
View File
@@ -18,7 +18,7 @@ export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null };
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
await assertLotseChatUsable(ctx);
await assertLotseChatUsable(ctx, { send: true }); // L17: dictation only while sending is possible
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
const sniffed = sniffMime(bytes);
+144
View File
@@ -0,0 +1,144 @@
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
import {
chatOverage,
chatQuota,
isQuotaBlocked,
monthKeyOfDay,
nextMonthKey,
overagePacks,
previousMonthKey,
usageLevel,
usagePercent,
type TenantTier,
type UsageLevel,
} from "@/lib/plans";
import { dbForTenant } from "@/server/db";
import { getTenantPlan } from "@/server/plan";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { countAssignedSeats, countEligibleUsers } from "./seats";
/**
* L17 Pakete — usage of the Lotse chat: ONE chat = one message of a user to the Lotse
* (`LotseMessage.role = user`). Counted per calendar month in the tenant timezone, for the whole tenant
* and per user. Quota = counted seats × 150 (trial: eligible users × 150). Overage keeps counting and is
* billed by the operator in packs of 100; with the hard limit the chat is blocked at the quota.
*
* Note: the Lotse retention job deletes chat histories after `AI_GENERATION_RETENTION_DAYS` (default
* 180) — keep it above ~62 days so the previous month stays complete for billing.
*/
const MONTH_KEY_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
export function isMonthKey(v: unknown): v is string {
return typeof v === "string" && MONTH_KEY_RE.test(v);
}
/** [from, to) instants of a calendar month in the timezone (DST-safe via day bounds). */
export function monthBounds(monthKey: string, timeZone: string): { from: Date; to: Date } {
if (!isMonthKey(monthKey)) throw new ServiceError("invalid", "month_invalid");
return { from: dayBounds(`${monthKey}-01`, timeZone).start, to: dayBounds(`${nextMonthKey(monthKey)}-01`, timeZone).start };
}
export function monthKeyOf(now: Date, timeZone: string): string {
return monthKeyOfDay(dayKeyOf(now, timeZone));
}
export type ChatUsage = {
monthKey: string;
from: string;
to: string;
tier: TenantTier;
isTrial: boolean;
seatsPurchased: number;
seatsAssigned: number;
/** seats that count for the quota (trial: eligible users) */
quotaBasis: number;
used: number;
quota: number;
overage: number;
overagePacks: number;
level: UsageLevel;
percent: number;
hardLimit: boolean;
/** hard limit reached → sending is blocked */
blocked: boolean;
perUser: { userId: string; name: string; count: number }[];
};
type UsageCtx = Pick<ServiceCtx, "db" | "tenantId">;
async function countChats(ctx: UsageCtx, from: Date, to: Date): Promise<number> {
return ctx.db.lotseMessage.count({ where: { role: "user", createdAt: { gte: from, lt: to } } });
}
async function countChatsPerUser(ctx: UsageCtx, from: Date, to: Date): Promise<ChatUsage["perUser"]> {
const groups = await ctx.db.lotseMessage.groupBy({ by: ["conversationId"], where: { role: "user", createdAt: { gte: from, lt: to } }, _count: { _all: true } });
if (!groups.length) return [];
const conversations = await ctx.db.lotseConversation.findMany({ where: { id: { in: groups.map((g) => g.conversationId) } }, select: { id: true, userId: true } });
const userOf = new Map(conversations.map((c) => [c.id, c.userId]));
const perUser = new Map<string, number>();
for (const g of groups) {
const userId = userOf.get(g.conversationId);
if (userId) perUser.set(userId, (perUser.get(userId) ?? 0) + g._count._all);
}
const users = await ctx.db.user.findMany({ where: { id: { in: [...perUser.keys()] } }, select: { id: true, name: true } });
const nameOf = new Map(users.map((u) => [u.id, u.name]));
return [...perUser.entries()].map(([userId, count]) => ({ userId, name: nameOf.get(userId) ?? "—", count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}
/**
* Usage of one month (default: current month in the tenant timezone). No permission check — callers are
* the chat gate (own tenant), the tenant admin view (`getChatUsageForAdmin`) and the operator view.
*/
export async function getChatUsage(ctx: UsageCtx, opts: { monthKey?: string; now?: Date; perUser?: boolean } = {}): Promise<ChatUsage> {
const timeZone = await tenantTimezone(ctx as ServiceCtx);
const monthKey = opts.monthKey ?? monthKeyOf(opts.now ?? new Date(), timeZone);
const { from, to } = monthBounds(monthKey, timeZone);
const plan = await getTenantPlan(ctx.tenantId);
const [used, seats, eligible, perUser] = await Promise.all([
countChats(ctx, from, to),
countAssignedSeats(ctx, plan.lotseChatSeats),
plan.isTrial ? countEligibleUsers(ctx) : Promise.resolve(0),
opts.perUser === false ? Promise.resolve([]) : countChatsPerUser(ctx, from, to),
]);
const quotaBasis = plan.isTrial ? eligible : seats.counted;
const quota = chatQuota(quotaBasis);
const overage = chatOverage(used, quota);
// the hard limit does not apply to a trial (no purchased seats; the token budget stays the safety net)
const hardLimit = plan.lotseChatHardLimit && !plan.isTrial;
return {
monthKey,
from: from.toISOString(),
to: to.toISOString(),
tier: plan.tier,
isTrial: plan.isTrial,
seatsPurchased: plan.lotseChatSeats,
seatsAssigned: seats.assigned,
quotaBasis,
used,
quota,
overage,
overagePacks: overagePacks(overage),
level: usageLevel(used, quota),
percent: usagePercent(used, quota),
hardLimit,
blocked: isQuotaBlocked(used, quota, hardLimit),
perUser,
};
}
/** Tenant admin (`tenant:manage`): current month with per-user breakdown. */
export async function getChatUsageForAdmin(ctx: ServiceCtx, opts: { now?: Date } = {}): Promise<ChatUsage> {
assertCan(ctx, "tenant:manage");
return getChatUsage(ctx, { now: opts.now, perUser: true });
}
/** Operator view (platform admin, see services/plans/platform.ts): current and previous month. */
export async function getChatUsageForOperator(tenantId: string, now: Date = new Date()): Promise<{ current: ChatUsage; previous: ChatUsage }> {
const ctx: UsageCtx = { db: dbForTenant(tenantId), tenantId };
const current = await getChatUsage(ctx, { now, perUser: false });
const previous = await getChatUsage(ctx, { monthKey: previousMonthKey(current.monthKey), perUser: false });
return { current, previous };
}
+15 -2
View File
@@ -5,6 +5,8 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { getTenantPlan } from "@/server/plan";
import { isModuleInTier } from "@/lib/plans";
import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
/**
@@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
export const LOTSE_MODULE_KEY = "lotse";
/** Lotse usable for the tenant: module switched on AND included in the package tier (L17: Basis has no AI). */
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
if (!(await isModuleInPlan(ctx.tenantId, LOTSE_MODULE_KEY))) return false;
return isLotseSwitchedOn(ctx);
}
/** Only the tenant switch (settings form shows it independently of the tier). */
async function isLotseSwitchedOn(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
const row = await ctx.db.tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
select: { enabled: true },
@@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">):
return !row || row.enabled;
}
async function isModuleInPlan(tenantId: string, moduleKey: string): Promise<boolean> {
return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey);
}
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
export async function getLotseSettings(ctx: ServiceCtx) {
assertCan(ctx, "tenant:manage");
const [enabled, s, budget] = await Promise.all([
isLotseEnabled(ctx),
isLotseSwitchedOn(ctx),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
getTokenBudget(ctx),
]);
@@ -73,7 +86,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
const before = {
enabled: await isLotseEnabled(ctx),
enabled: await isLotseSwitchedOn(ctx),
addressForm: stored?.lotseAddressForm ?? null,
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
chatEnabled: stored?.lotseChatEnabled ?? true,
+4
View File
@@ -1,10 +1,13 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
/**
* Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden.
* L17 Pakete: planning is a PROFI feature — Basis → `forbidden` (reason `not_in_plan`) for board, live
* situation, recommendations, freed capacity, dashboard tiles and the planning API.
*/
export type PlanningAccess = {
all: boolean;
@@ -15,6 +18,7 @@ export type PlanningAccess = {
};
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
await assertPlanFeature(ctx.tenantId, "planning");
if (can(ctx, "work_order:read_all")) {
return {
all: true,
+2
View File
@@ -3,6 +3,7 @@ import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/pl
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
@@ -44,6 +45,7 @@ export type ScheduleResult = {
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
assertCan(ctx, "work_order:assign");
assertCan(ctx, "work_order:write");
await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete
const input = parseInput(scheduleSchema, raw);
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
@@ -1,6 +1,7 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
import { parseInput } from "@/server/services/work-orders/_shared";
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
@@ -13,6 +14,7 @@ export type TeamPlanningSettingsInput = z.input<typeof settingsSchema>;
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
assertCan(ctx, "team:manage");
await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete: Kolonnenkapazität gehört zur Planung
const input = parseInput(settingsSchema, raw);
const before = await ctx.db.team.findFirst({
where: { id: teamId, deletedAt: null },
+3
View File
@@ -15,6 +15,7 @@ import { emitEvent } from "@/server/events";
import { PERMISSIONS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import { isPlanFeatureAvailable } from "@/server/plan";
import {
loadPlannedOrders,
loadTeams,
@@ -339,6 +340,8 @@ export async function runPlanningWatch(
): Promise<WatchResult> {
const ctx = watchContext(tenantId);
const now = opts.now ?? new Date();
// L17 Pakete: Basis enthält keine Planung → keine Verzugs-/Frühfertig-Meldungen
if (!(await isPlanFeatureAvailable(tenantId, "planning"))) return { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
const emit = opts.emit ?? emitEvent;
const ledger = opts.ledger ?? auditAlertLedger;
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
+66
View File
@@ -0,0 +1,66 @@
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<void> {
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<typeof tenantPlanSchema>;
export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise<TenantPlanInfo> {
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<TenantPlanOverview> {
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 };
}
+40
View File
@@ -14,6 +14,7 @@ import { attachPhoto } from "@/server/services/field/photos";
import { attachVoiceNote } from "@/server/services/field/voice";
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { moduleState, PROFI_ONLY_MESSAGE } from "@/server/plan";
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
/**
@@ -78,6 +79,37 @@ function errorMessage(err: ServiceError): string {
class NotAvailable extends Error {}
/**
* L17 Pakete: sync ops that belong to a module other than `field` (the sync route itself is gated by
* `field`). One place for all offline ops — the effective module state comes from src/server/plan.ts.
*/
export const OP_MODULES: Partial<Record<SyncOpType, string>> = {
"emergency.create": "emergency",
"milestone.reach": "billing",
};
const LOCKED_MODULE_MESSAGE: Record<string, { not_in_plan: string; disabled: string }> = {
emergency: {
not_in_plan: "Notdienst ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
disabled: "Notdienst ist für diesen Betrieb ausgeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
},
billing: {
not_in_plan: "Meilensteine gehören zur Abrechnungsübersicht (Paket Profi) – die Meldung wurde nicht übernommen.",
disabled: "Die Abrechnungsübersicht ist für diesen Betrieb ausgeschaltet – die Meldung wurde nicht übernommen.",
},
};
async function lockedModuleMessage(ctx: ServiceCtx, opType: SyncOpType): Promise<string | null> {
const moduleKey = OP_MODULES[opType];
if (!moduleKey) return null;
const state = await moduleState(ctx.tenantId, moduleKey);
if (state === "enabled") return null;
// `<code>: <plain text>` like `other_session_running:…` — the device maps the code to its own texts
// (lib/offline/outbox-core.ts#problemKey), other API clients show the plain text.
const text = LOCKED_MODULE_MESSAGE[moduleKey]?.[state] ?? (state === "not_in_plan" ? PROFI_ONLY_MESSAGE : "Modul ist für diesen Betrieb ausgeschaltet.");
return `${state === "not_in_plan" ? "not_in_plan" : "module_disabled"}: ${text}`;
}
/** Route a validated op to the field handler or the registered module of another lane. */
async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise<HandlerResult> {
const handler = FIELD_HANDLERS[op.opType];
@@ -188,6 +220,14 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message };
}
// 2b. L17 Pakete: ops of a module that is switched off or not part of the package tier → rejected
// with plain text (stored, visible in the device's sync list — never silently dropped)
const locked = await lockedModuleMessage(ctx, op.opType);
if (locked) {
const rec = await record(ctx, op, deviceId, "rejected", { message: locked }, "forbidden");
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "forbidden", message: locked };
}
try {
// 3. conflict check
if (CONFLICTING_OPS.includes(op.opType)) {
@@ -12,6 +12,7 @@ import {
type WorkOrderBase,
} from "@/server/services/work-orders/_shared";
import { transitionBlockers } from "@/server/services/work-orders/completion";
import { isModuleActive } from "@/server/plan";
/** Permission decision for a single transition (scope is checked separately by loading the order). */
export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean {
@@ -116,8 +117,7 @@ export async function applyTransition(
return { id: wo.id, status: to, version, from };
}
/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule). */
/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule; L17: and part of the package tier). */
export async function billingModuleActive(ctx: ServiceCtx): Promise<boolean> {
const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
return !row || row.enabled;
return isModuleActive(ctx.tenantId, "billing");
}