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