Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
Claude
fddaa46d7d Interne Entwicklungswerkzeuge entfernen
Night-Agent (ops/night-agent.mjs, Leitplanken, Doku) und das Skript zur
Lizenzsystem-Erweiterung werden für den Betrieb einer Installation nicht
gebraucht. README-Hinweis entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:51:49 +02:00
Claude
fca2b9d746 Version 0.0.1 (Vorabversion)
Erste öffentliche Vorabversion statt 1.0.0: noch kein endgültiger Release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:46:01 +02:00
Claude
17267b7c05 KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen
Testmodus (ohne gültige Lizenz): 1 Personal-Konto, 2 Kunden; Discord-Bot,
E-Mail-Posteingang (IMAP), DATEV-Export und Backups auf externe Ziele nur mit
Lizenz. Mit Lizenz gelten deren Grenzen und Module (Lizenz ohne Modulliste =
voller Umfang). Bestehende Daten bleiben bei Ablauf vollständig nutzbar, nur
Neuanlagen über die Grenzen und die Zusatzfunktionen pausieren.

- Edition zentral in @kc/platform/license (getEdition, hasFeature,
  assertCustomerCapacity, assertStaffCapacity), auch in der CLI durchgesetzt
- Einstellungen → Lizenz zeigt die Edition, Hinweisbanner im Testmodus
- README mit Installation, Update und Betrieb; systemd-Units unter ops/systemd
- .env.example bereinigt (Kommentare nicht mehr hinter Werten, veraltete
  SMTP/Discord-Variablen entfernt; beides wird in der Oberfläche eingestellt)
- LICENSE.md: Nutzungsbedingungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:53 +02:00
Claude
fdd51b18e2 Lizenzen: Kunde auf der Detailseite zuweisen, ändern oder Zuordnung lösen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:06:14 +02:00
Claude
a322166d01 Lizenzverwaltung: Übersicht, Vergabe, Aktivierungen, Lebenszyklus und Kundensicht
Neuer Menüpunkt "Lizenzen" (Personal) bzw. "Meine Lizenzen" (Kunden):
- Übersicht mit Kennzahlen, Filtern (aktiv, läuft in 30 Tagen ab, gesperrt,
  abgelaufen, ohne Kunde) und Suche; Dashboard-Kachel
- Vergabe mit Vertrag (normaler Bestellweg) oder ohne Berechnung direkt im
  Lizenzsystem: Lizenz, Test (Trial) oder Add-on zu einer Basislizenz
- Detailseite: Schlüssel, Geräte (Aktivierungen) freigeben, Sperren/Entsperren,
  Verlängern, Widerrufen, Limits, Funktionsumfang (Entitlement), Produktwechsel
  bzw. Testumwandlung, Add-ons, Verlauf
- Kunden-Selbstbedienung: Inhaber/Admin geben eigene Geräte frei

Verwaltungs-Client für das Lizenzsystem in @kc/connector-licensing (createLicensingAdmin),
Fehlermeldungen des Lizenzsystems werden verständlich weitergereicht. Alle Änderungen
im Audit-Protokoll; der lokale Stand wird danach sofort aktualisiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:54:12 +02:00
Claude
9336f46d0a Discord: Support-Antworten im Ticket-Kanal als Antwort ins Ticket übernehmen
Nachrichten von verknüpftem Personal im Ticket-Kanal/-Thread werden wie eine
Antwort im Kundencenter behandelt: Support-Nachricht, Status 'wartet auf Kunde',
Mail an den Ersteller, Audit-Eintrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:29:36 +02:00
Claude
8ef0b3bc05 Discord: Tickets als eigene Kanäle in einer Kategorie, Ticket-Meldungen nur für den Support
Mit gesetzter Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal,
sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften
Mitglieder des Kunden. Ein minütlicher Abgleich im Worker legt fehlende Kanäle an
(auch für Tickets aus Mail-Eingang/Vertragsverlängerung) und entfernt Kanäle
geschlossener Tickets. Ticket-Meldungen gehen in einen eigenen Support-Kanal
(#ticket-log, wird automatisch angelegt) statt in den Systemmeldungs-Kanal.
Ohne Kategorie bleibt das bisherige Verhalten (private Threads) unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:11:51 +02:00
Kundencenter
711b99bb34 Fix contract adjust storing term end shifted by the server timezone
The date from the edit form was parsed as local midnight, so e.g.
2027-07-04 was stored as 2027-07-03 22:00 UTC, while all other contract
dates use UTC midnight. The audit entry also serialized the previous
term end as {}; both dates are now logged as ISO strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:29:21 +02:00
Kundencenter
c276eb8ceb Show staff-only products in the order catalog for staff
POST /orders already lets staff order products that customers cannot
order themselves, but the catalog hid them, so e.g. KeyHelp Unlimited
could not be booked for a customer. Staff now see all active products,
marked as staff-only where applicable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:24:48 +02:00
Kundencenter
466bd83b7e Let superadmins adjust contract price and terms
New PATCH /admin/contracts/:id (permission contracts.edit, superadmin
only) recalculates the price snapshot server-side and can change term
end, renewal and notice period. A reason is required and before/after
values go to the audit log. Changing the term end re-arms the renewal
reminder. The contract page gets a matching edit form.

Also fixes the domain test after replacing the KCS order number with
the order date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:02:26 +02:00
Kundencenter
d00f1a4cec Domain register: editable order date instead of KCS order number
KCS has no per-domain order number, so the field is replaced by the
order date (setting it also marks an open record as ordered). The CSV
export drops the order-number column accordingly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:57:20 +02:00
Kundencenter
1178e9edfe Show registered domains on hosting resource pages
The resource detail now lists the domain records linked to the hosting
account (term, registration status), so customers see the domains
included in their package. Prices and procurement state are staff-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:54:46 +02:00
Kundencenter
dad9528295 Add contract renewal reminder emails (keep/cancel decision links)
Sends a one-time email ~30 days before any contract's term_end
(whether renewal='auto' or 'none'), with "Behalten" and "Kündigen"
action links. "Behalten" extends the contract by one renewal term;
"Kündigen" sets the same cancellation effective-date logic as the
customer-facing cancel flow and opens a staff ticket so the external
deregistration (e.g. with a domain registrar) actually gets done.

Uses a dedicated single-use token table (contract_renewal_tokens)
since contracts, unlike user_tokens, are not scoped to a single user.
The public confirmation page lives at /vertrag-entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 11:51:19 +02:00
Kundencenter
29c7273f4d fix(web): Impersonation-Banner als volle Kopfzeile statt in der Sidebar-Spalte
Der Banner stand als erstes Kind in .shell (CSS-Grid mit Sidebar-Spalte +
Inhaltsspalte) und wurde dadurch selbst zur ersten Spalte gerechnet -
schob sich links neben den Rest und verschob das ganze Layout. Jetzt
außerhalb von .shell als eigene, volle Kopfzeile vor dem Grid, Text
zentriert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 11:18:52 +02:00
Kundencenter
e2a73f4e6d fix(web): Rules-of-Hooks-Verletzung auf der Kundenseite (React #310)
welcomeBusy's useState stand hinter dem frühen "noch am Laden"-Return,
wurde also erst ab dem zweiten Render (sobald die Kundendaten da sind)
aufgerufen - unterschiedliche Hook-Anzahl zwischen den Renders, siehe
React-Fehler #310. Bestand schon vor der heutigen Impersonation-Änderung
(seit der Begrüßungsmail-Funktion), ist aber in der Praxis bei jedem
Öffnen der Kundenseite aufgetreten ("This page couldn't load").

Hook vor den frühen Return verschoben. Rest der Datei und eine grobe
Suche über die restliche Web-App auf dasselbe Muster geprüft - keine
weiteren echten Treffer (nur falsch-positive in anderen, unabhängigen
Komponenten derselben Dateien).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 11:16:44 +02:00
Kundencenter
2ed9697ca6 feat(customers): Impersonation ("als Kunde ansehen") mit Kennzeichnung und Audit
Umsetzung von Ticket #35: Superadmin/Admin kann auf der Kundenseite "Als
Kunde ansehen" wählen und sieht das Kundencenter zeitlich begrenzt (60 min)
genau so, wie der Kunde es sieht - mit sichtbarem Banner und jederzeitigem
"Zurück zur Verwaltung".

Technisch keine zweite Anmeldung: die bestehende Staff-Sitzung wird um
Impersonation-Felder erweitert (Migration 036). loadAuth() schaltet den
Principal währenddessen echt auf kind='customer' mit einer Mitgliedschaft
(Rolle "owner") in genau dieser Organisation um - eine tatsächliche
Rechteeinschränkung, keine bloß andere Oberfläche: staff-only-Endpunkte
sind währenddessen wirklich nicht mehr erreichbar (can() liefert für
kind='customer' grundsätzlich false), und canInOrg-geschützte Routen
funktionieren unverändert für genau diese eine Organisation. Die echte
Staff-Identität (AuthContext.user) bleibt für das Audit unverändert erhalten.

- POST /admin/customers/:id/impersonate (Recht customers.impersonate, nur
  admin/superadmin): Grund Pflichtfeld, setzt die Sitzung, protokolliert
  customer.impersonate.start mit beiden Identitäten (actorId = echter
  Staff-Nutzer, orgId = Kunde).
- loadAuth() erkennt eine abgelaufene Impersonation automatisch, setzt die
  Sitzung zurück (kein harter Logout) und protokolliert
  customer.impersonate.end mit reason "expired".
- POST /auth/impersonate/stop: manuelles Beenden, reason "manual".
- Web: Banner in (app)/layout.tsx mit Grund und "Zurück"-Button; Formular
  mit Grund-Eingabe auf der Kundenseite.

Gegen die echte Produktionsdatenbank end-to-end verifiziert: Start/Stopp,
staff-only-Endpunkt während Impersonation korrekt mit 403 abgelehnt, eigene
Organisation lesbar (200), fremde Organisation weiterhin 404 (keine
Existenz verraten), künstlich abgelaufene Sitzung fällt automatisch auf
die Staff-Identität zurück, Audit-Einträge zeigen durchgehend beide
Identitäten. Erfüllt die im Ticket vorgegebenen Testschritte vollständig.
Testsuite 58/59 (vorbestehende, unabhängige Flakiness in orders.test.ts).
Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 10:59:25 +02:00
Kundencenter
c52996b559 feat(license): Kundenzähler und Upgrade-Hinweise in der Lizenzübersicht (#117)
Zeigt jetzt den tatsächlichen Kundenstand (aktive + gesperrte Organisationen)
gegen die Kundengrenze, nicht nur die Grenze selbst. Dazu Warnungen bei
erreichter Grenze (≥100%), kurz davor (≥90%) und bei bald endendem
Testzeitraum (<7 Tage) - erste Teile des Lizenzübersicht-Tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 10:50:49 +02:00
Kundencenter
d033088223 feat(license): Kundencenter-Lizenzierung gegen licensing.flessinglabs.com (#116)
Milestone "Kundencenter lizenzierbar machen": die Installation prüft jetzt
ihre eigene Lizenz beim Lizenzsystem (licensing.flessinglabs.com, Programm
"Kundencenter") und setzt die Kundengrenze serverseitig durch - nicht zu
verwechseln mit dem bestehenden "licensing"-Connector, der KUNDEN-Lizenzen
verwaltet, die das Kundencenter weiterverkauft.

- Migration 035: license_state (ein Lizenzschlüssel, verschlüsselt wie
  andere Secrets, stabile instance_id, zuletzt geprüfter Entitlement-
  Snapshot), license_signing_keys (öffentliche Signaturschlüssel, werden
  nur ergänzt, nie anhand einer Serverantwort gelöscht).
- @kc/platform/license: checkLicenseNow() ruft POST /license/verify mit
  X-Program-Key auf, prüft die FLS1-Signatur (RSA-2048, PKCS#1 v1.5,
  SHA-256) und schreibt das Ergebnis in license_state. getEntitlement()
  liefert den nutzbaren Stand inkl. Offline-Fenster (Standard 7 Tage,
  verlängert nie eine tatsächlich abgelaufene Lizenz). assertCustomerCapacity()
  sperrt neue/reaktivierte Kunden transaktional (GET_LOCK gegen den letzten
  freien Platz) bei erreichter Kundengrenze oder ungültiger Lizenz - aber
  NICHT, solange die Lizenzierung noch gar nicht konfiguriert oder noch nie
  geprüft wurde (sonst wäre jede frische Installation von Anfang an blockiert).
- Durchsetzung an allen drei Stellen, die einen neuen Kundenplatz belegen:
  Kundenanlage, Reaktivierung (closed→active) und KeyHelp-Import neuer
  (nicht verknüpfter) Kunden.
- Worker prüft alle 12 h automatisch (zusätzlich zur Prüfung beim Speichern
  eines neuen Schlüssels). API: GET/PUT /admin/license/settings,
  POST /admin/license/check. UI: Einstellungen → Lizenz (Status, Module,
  Kundengrenze, Schlüssel eingeben, manuell prüfen).

Beim Testen einen echten Bug gefunden und behoben: Buffer.from(hex,'hex')
verwirft bei ungerader Hex-Länge stillschweigend die letzte Ziffer (der
Exponent "10001"/65537 wurde fälschlich zu 0x1000) - hätte jede Signatur-
prüfung unbemerkt scheitern lassen. Mit geradem Padding gegen echte
Serverantworten (gültig und manipuliert) verifiziert.

Gegen die echte Produktions-API getestet (Program-Key aus LICENSE_PROGRAM_KEY):
Status/Settings/Check-Endpunkte, Signaturprüfung mit echten Schlüsseln
erfolgreich, Kundenanlage korrekt blockiert bei geprüfter ungültiger Lizenz,
danach wieder sauber in den unkonfigurierten (nicht blockierenden) Zustand
zurückgesetzt, da noch kein echter Lizenzschlüssel für diese Installation
vorliegt. Testsuite 58/59 (eine vorbestehende, unabhängige datumsabhängige
Flakiness in orders.test.ts, auch ohne diese Änderungen reproduzierbar).
Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün.

Offen: Lizenzschlüssel für diese Installation selbst anlegen (Admin-Panel
licensing.flessinglabs.com) und unter Einstellungen > Lizenz eintragen.
Modul-Gates (billing/domains/provisioning/discord/automation) an den
einzelnen Funktionsbereichen sowie die Lizenzübersicht-Admin-UI (#117)
sind noch zu bauen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 10:48:48 +02:00
Kundencenter
3240e6df79 feat(audit): Aufbewahrungs-Policy mit Checkpoint-Löschung und CSV-Export
Zweiter Teil von #34: Aufbewahrungsfristen und Export waren offen. Die
DSGVO selbst schreibt keine feste Zahl vor (nur den Grundsatz der
Speicherbegrenzung, Art. 5 Abs. 1 lit. e) - daher eine eigene, änderbare
Policy statt eines hart codierten Gesetzeswerts. Voreinstellung 180 Tage
als verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten.

- Migration 034: audit_settings (retention_days, Default 180) und
  audit_retention_checkpoints.
- purgeAuditRetention() (@kc/platform/audit): löscht Einträge jenseits
  der Frist. Da jede Zeile die vorherige mit hasht, würde einfaches
  Löschen die Kette brechen - ein Checkpoint (Hash der zuletzt gelöschten
  Zeile) macht sie trotzdem weiter überprüfbar. Bricht nur ab, wenn die
  Kette vorher schon fehlerhaft ist, oder bei einer Lücke zwischen
  gelöschten und verbleibenden Zeilen (unerwartete Zeitstempel-
  Reihenfolge). audit_events ist per Trigger unveränderlich (Migration
  033) - der DELETE-Trigger wird dafür kurz entfernt und sofort wieder
  angelegt, alles unter derselben Sperre wie audit() (re-entrant über
  dieselbe Verbindung, sonst Deadlock). Die Aufräumung selbst wird als
  eigener Audit-Eintrag protokolliert.
- verifyAuditChain() beginnt nach einer Aufräumung beim Checkpoint-Hash
  statt der Genesis-Null - nur wenn die neue erste Zeile auch wirklich
  genau darauf verweist, sonst bliebe eine echte Manipulation unentdeckt.
- Worker: täglicher automatischer Lauf. CLI: audit-purge zum manuellen
  Anstoßen. API: GET/PUT /admin/audit/settings, POST /admin/audit/purge
  (settings.write), GET /admin/audit/export.csv (audit.read, mit
  demselben Formel-Injection-Schutz wie der Rechnungs-Export). Web:
  Einstellungs-Card unter Audit-Protokoll mit Erklärtext zur DSGVO-
  Rechtsgrundlage, Frist-Eingabe, "Jetzt aufräumen" und Export-Link.

Verifiziert: Testsuite (59/59), echter Lauf mit künstlich vordatierten
(aber korrekt verketteten) Testzeilen gegen die Testdatenbank - Kette vor
und nach der Aufräumung intakt, Checkpoint korrekt genutzt, zweiter Lauf
idempotent. Echte HTTP-Endpunkte (inkl. CSRF) gegen Produktion getestet.
Volles Backup+Wiederherstellungstest danach weiterhin grün (201
Einträge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 12:01:00 +02:00
Kundencenter
5ec8a526b3 fix(audit): audit_events auf DB-Ebene unveränderlich machen (append-only)
Nightbot-Befund #34 (Teil 1 von 2 – "eigene DB-Rolle für Audit ohne
UPDATE/DELETE"): eine echte separate DB-Rolle nur für Audit-Schreibzugriffe
ist mit dem bestehenden Muster (audit() schreibt oft in derselben
Transaktion wie die Fachaktion, die sie protokolliert, über dieselbe
Connection) nicht sauber vereinbar, ohne diese Atomarität zu verlieren
oder alle ~50 Tabellen einzeln neu zu berechtigen (MySQL/MariaDB kann
eine datenbankweite Berechtigung nicht durch eine engere Tabellen-
Berechtigung "überschreiben" – Rechte sind additiv, nicht spezifischer
gewinnt).

Stattdessen: BEFORE UPDATE/DELETE-Trigger auf audit_events, die beides
unabhängig vom verbindenden DB-Nutzer grundsätzlich verweigern (Migration
033). INSERT/SELECT bleiben uneingeschränkt möglich – genau das, was
audit()/verifyAuditChain() je brauchen. TRUNCATE bleibt technisch möglich
(feuert keine Trigger, betrifft nur die Testdatenbank-Zurücksetzung
zwischen Testdateien), DROP TABLE weiterhin auch – echte Kompromittierung
der DB-Zugangsdaten bleibt außerhalb dieser Verteidigungslinie, aber
versehentliche oder fehlerhafte Anwendungscode-Änderungen sind jetzt
ausgeschlossen.

test/core.test.ts angepasst: die bisherige Manipulationssimulation per
UPDATE ist jetzt selbst Teil des Tests (muss fehlschlagen); die Prüfung
"Hash-Kette erkennt Fälschung" simuliert stattdessen eine eingeschleuste
Fälschung per INSERT (weiterhin erlaubt).

Verifiziert: Testsuite (59/59, neuer Testfall für die Trigger-Ablehnung),
echte UPDATE/DELETE-Versuche gegen die Produktionsdatenbank beide
abgelehnt, Hash-Kette danach weiterhin intakt (192 Einträge), volles
Backup+Wiederherstellungstest gegen die echte Produktionsdatenbank
gefahren (Trigger werden korrekt mitgesichert/wiederhergestellt).

Aufbewahrungsfristen (DSGVO) und Export sind Teil desselben Tickets,
brauchen aber eine fachliche/rechtliche Entscheidung des Nutzers und
bleiben offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:49:26 +02:00
Kundencenter
a9893b6d32 fix(worker): Lease/Heartbeat statt starrer 5-Minuten-Grenze bei Jobwiederaufnahme
Nightbot-Befund #99/#16 (P1): recoverStale gab "running"-Jobs nach starren
fünf Minuten wieder frei, ohne zu prüfen, ob der ursprüngliche Worker noch
aktiv daran arbeitet. Ein legitim länger laufender Job (z. B. ein
langsamer Connector-Abgleich) konnte dadurch von einem zweiten Worker
parallel erneut gestartet werden – Doppelausführung, z. B. doppelte
Provisionierung oder doppelter Mailversand.

- Migration 032: jobs.lease_id (pro Übernahme neu vergeben) und
  jobs.locked_by (Worker-Kennung, nur Diagnose).
- runOnce vergibt beim Übernehmen einen frischen Lease und hält locked_at
  per Heartbeat alle 60 s aktuell, solange der Handler läuft. Jedes
  Abschluss-UPDATE (Erfolg wie Fehler) ist an genau diesen Lease gebunden
  (WHERE lease_id = ?); hat recoverStale die Zeile inzwischen doch
  freigegeben, verpufft ein verspätetes Ergebnis wirkungslos statt den
  neuen Versuch zu überschreiben.
- recoverStale reißt jetzt nur noch Jobs an sich, deren Heartbeat
  tatsächlich ausgeblieben ist (locked_at älter als 5 Minuten), nicht
  mehr solche, die einfach nur lange laufen. Löscht lease_id beim
  Freigeben mit.

Verifiziert: (a) SQL-Ebene direkt durchexerziert – frischer Heartbeat
verhindert das Anreißen, ausgebliebener Heartbeat löst es aus, ein
verspätetes Abschluss-UPDATE mit altem Lease betrifft 0 Zeilen; (b) echter
Code über runOnce/recoverStale gegen die Testdatenbank – Job durchläuft
Übernahme, Fehlschlag, lease-gebundenes Abschluss-UPDATE korrekt. Worker
neu gestartet, läuft fehlerfrei, 1437 bestehende Jobs weiterhin 'succeeded'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:41:43 +02:00
Kundencenter
b64cec21a4 fix(cli): Migrationen mit Prüfsumme und Sperre gegen Parallelausführung härten
Zweite Hälfte von #100 (P2, Nightbot-Befund): schema_migrations kannte
bisher nur den Dateinamen, keine Prüfsumme. Wurde eine bereits
angewendete Migrationsdatei nachträglich bearbeitet (versehentlich,
Merge-Konflikt o. Ä.), lief der nächste `pnpm migrate` klaglos durch –
die Änderung wurde nie angewendet, Umgebungen konnten unbemerkt
auseinanderdriften. Außerdem gab es keine Sperre: zwei gleichzeitige
Migrationsläufe (z. B. zwei parallele Deploys) hätten sich in die
Quere kommen können.

- Neue Spalte schema_migrations.checksum (SHA-256 des Dateiinhalts,
  ALTER TABLE ... ADD COLUMN IF NOT EXISTS für bestehende Installationen).
  Bereits angewendete Migrationen ohne gespeicherte Prüfsumme werden
  einmalig nachgetragen; weicht eine vorhandene Prüfsumme vom aktuellen
  Dateiinhalt ab, bricht die Migration mit klarer Fehlermeldung ab statt
  die Änderung stillschweigend zu ignorieren.
- GET_LOCK('kc_migrate', 10) um den gesamten Lauf (selbes Muster wie die
  Audit-Hash-Kette), verhindert parallele Migrationsläufe.
- Sauberer Fehlerausstieg (Meldung + Exit-Code) statt Stack-Trace.

Gegen die Testdatenbank durchexerziert: Prüfsummen-Nachtrag, No-Op-
Wiederholung, manipulierte Prüfsumme (bricht korrekt ab), danach wieder
sauberer Lauf. Gegen die echte Produktionsdatenbank angewendet: alle 31
bestehenden Migrationen jetzt mit Prüfsumme, kc-api läuft weiter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:36:52 +02:00
Kundencenter
881f19ed05 fix(cli): Passwort nie mehr als Klartext-Argument, neuer reset-password-Befehl
Vom Nightbot-Nachtriage-Durchlauf gefunden (#100) und vom Nutzer bestätigt:
create-superadmin --password=… landete im Klartext in argv – für jeden
lokalen Nutzer über `ps aux` und in der Shell-History sichtbar.

- create-superadmin fragt das Passwort jetzt interaktiv ohne Echo ab (wie
  `passwd`), zweimal zur Bestätigung. Für Skripte/Automatisierung optional
  über die Umgebungsvariable KC_CLI_PASSWORD (landet nicht in argv/ps,
  nur im Prozess-Environ des ausführenden Nutzers/root).
- Neuer Befehl reset-password --email=… : bisher gab es KEINEN Weg, das
  Passwort eines bestehenden Nutzers (insb. eines ausgesperrten
  Superadmins) zurückzusetzen außer über die Selbstbedienung per E-Mail-
  Link (die bei fehlendem Postfachzugriff oder noch nicht eingerichtetem
  SMTP nicht hilft) oder direkte DB-Manipulation. Funktioniert für jeden
  Nutzer (Kunde oder Personal), setzt failed_logins/locked_until zurück,
  protokolliert im Audit-Log.

Beide Befehle scheitern jetzt sauber (Fehlermeldung + Exit-Code, kein
Stack-Trace) statt mit --password=<12+ Zeichen> zu arbeiten.

Gegen die Testdatenbank durchexerziert: Anlegen, doppelte E-Mail, zu
kurzes Passwort, Reset, unbekannte E-Mail, fehlendes TTY ohne
KC_CLI_PASSWORD – alle Pfade verhalten sich wie erwartet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:32:26 +02:00
Kundencenter
1bda3ac1aa fix(invoices): CSV-Formel-Injection im Export verhindern
Beim Nightbot-Nachtriage-Durchlauf gefunden: Firmenname und Notiz im
Rechnungsjournal-CSV (aus Kunden-/Personal-Eingaben) landeten ungeprüft
in der Zelle. Beginnt ein solcher Wert mit =, +, - oder @, interpretieren
Excel/Sheets ihn beim Öffnen als Formel statt als Text (CSV-Injection).

Zellen mit einem dieser Startzeichen werden jetzt mit einem führenden
Apostroph entschärft (Standard-Gegenmaßnahme), sowohl im generischen
CSV-Export als auch im DATEV-Buchungstext (dort fließt der Firmenname
ebenfalls mit ein).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:25:27 +02:00
Kundencenter
12fec5c822 feat(rechnungen): Export für den Steuerberater (CSV, PDF-ZIP, DATEV-Buchungsstapel)
Neue Seite Rechnungen → Export mit Zeitraum-/Status-Filter und drei
Downloads, nie Entwürfe:

- Rechnungsjournal als CSV (Semikolon, Dezimalkomma, UTF-8-BOM):
  Nummer, Datum, Kunde, Netto/USt/Brutto, effektiver USt-Satz, Status,
  Zahlungsdatum, aufgelöste Storno-Referenzen.
- Alle zugehörigen Rechnungs-PDFs als ZIP (die eigentlichen Belege,
  wie von GoBD neben dem Journal verlangt), gebaut über das lokale
  zip-Binary wie in ops/backup.ts.
- Echter DATEV-Buchungsstapel (EXTF, Format 700/21) zum direkten
  Import beim Steuerberater. Pro Rechnung eine Buchungszeile je
  USt-Satz-Gruppe (Automatikkonten-Verfahren: Konto = aus der
  Kundennummer abgeleitetes Debitorenkonto, Gegenkonto = konfiguriertes
  Erlöskonto des jeweiligen Satzes – kein geratener BU-Schlüssel
  nötig). Kopf- und Buchungszeilen-Layout (31 bzw. 125 Felder) gegen
  die offizielle DATEV-Formatbeschreibung und ein reales Beispiel aus
  github.com/ledermann/datev verifiziert, nicht aus dem Gedächtnis
  geraten. Ausgabe in Windows-1252 (eigener Encoder: Node kennt nur
  striktes ISO-8859-1, das den Halbgeviertstrich in den DATEV-
  Spaltennamen stillschweigend verstümmelt hätte – beim Testlauf
  gefunden und behoben).

Dafür neue DATEV-Stammdaten unter Einstellungen → Firma (Berater-/
Mandantennummer, SKR, Sachkontenlänge, Wirtschaftsjahresbeginn,
Erlöskonten je Steuersatz; Migration 031). Ohne diese Angaben liefert
der DATEV-Export eine klare Fehlermeldung statt einer falschen Datei;
das CSV/ZIP funktioniert unabhängig davon immer.

Gegen echte Rechnungen (RE-1001/RE-1002, Original + Storno) über eine
temporäre Test-Session verifiziert: CSV/ZIP/DATEV liefern korrekte
Inhalte, Storno kehrt Soll/Haben korrekt um, Feldzahlen exakt 31/125,
Encoding rund-trip-fest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:05:56 +02:00
Kundencenter
8cf8404440 fix(backup): Ticket-Anhänge in Sicherung und Wiederherstellungstest einschließen
Vom Nacht-Agenten gefundene Lücke (P1), gegen den echten Code verifiziert:
das Backup-Archiv enthielt nur db.sql/config/manifest.json, nicht das
Anhänge-Verzeichnis (STORE_ROOT, /var/lib/kundencenter/ticket-attachments).
Bei einem echten Datenverlust wären alle Ticket-Anhänge unwiederbringlich
verloren gewesen, obwohl die Datenbank-Referenzen darauf erhalten blieben.

- runBackup: Anhänge-Verzeichnis wird mit in archive.tar.gz gepackt (eigener
  -C-Abschnitt, da es außerhalb des Arbeitsverzeichnisses liegt), Datei-/
  Byte-Anzahl landet zur Kontrolle im manifest.json.
- runRestoreTest: prüft nach dem Entpacken, ob Datei- und Byte-Anzahl der
  Anhänge mit dem Manifest übereinstimmen (wie die bestehenden Zeilenzahl-/
  Migrations-/Audit-Kette-Prüfungen). Ältere Sicherungen ohne dieses Feld
  werden übersprungen statt fälschlich als fehlerhaft gemeldet.

Gegen eine echte Sicherung+Wiederherstellungstest verifiziert (zwei
Testdateien im Anhänge-Verzeichnis angelegt, Backup gefahren, Restore-Test
lief grün mit anhaengeDateien: 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:44:24 +02:00
Kundencenter
acf8751f9f fix(audit): GET_LOCK-Ergebnis prüfen, connector/ip in Hash-Kette
Zwei vom Nacht-Agenten gefundene Lücken, gegen den echten Code
verifiziert und behoben:

1. Das Ergebnis von GET_LOCK() wurde nie geprüft. Bei Timeout (10s)
   oder Fehler lief audit() trotzdem ungesperrt weiter – zwei
   gleichzeitige Aufrufe hätten dieselbe "letzte Zeile" lesen und
   beide anhängen können, was die Kettengarantie bricht. Wirft jetzt
   einen Fehler, wenn die Sperre nicht erlangt wurde.

2. connector und ip wurden zwar in audit_events gespeichert, aber nie
   mitgehasht – beide Felder ließen sich im Nachhinein unbemerkt
   ändern, ohne die Kette zu brechen. Neue Einträge (hash_version = 2,
   Migration 030) hashen sie jetzt mit. Bestehende Einträge (Version 1)
   bleiben mit ihrer ursprünglichen Formel gültig; verifyAuditChain()
   berücksichtigt die Version pro Zeile.

Gegen die echte Datenbank verifiziert: Kette bleibt über alle 183
bestehenden (v1) Einträge sauber, ein neu eingefügter (v2) Eintrag mit
connector/ip ebenfalls – 184/184 ohne Bruch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:56:46 +02:00
83 changed files with 2859 additions and 638 deletions

View file

@ -1,21 +1,21 @@
# Beispielwerte OHNE Geheimnisse. Echte Werte liegen in /etc/kundencenter/*.env (chmod 600).
# Vorlage OHNE Geheimnisse. Echte Werte nach /etc/kundencenter/db.env und /etc/kundencenter/app.env (chmod 600, root).
# Wichtig: Kommentare nur in eigenen Zeilen, nie hinter einem Wert.
# ---- /etc/kundencenter/db.env ----
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=kundencenter
DB_USER=kundencenter
DB_PASSWORD=
KC_SECRET_KEY= # 32 Byte base64 (openssl rand -base64 32); Verschluesselung von TOTP-Secrets
KC_BASE_URL=http://localhost:4101
# ---- /etc/kundencenter/app.env ----
# Master-Schlüssel für 2FA- und Zugangsdaten-Verschlüsselung: openssl rand -base64 32
# Unbedingt sichern – ohne ihn sind verschlüsselte Daten nach einer Wiederherstellung unlesbar.
KC_SECRET_KEY=
# Exakt die Adresse, unter der das Kundencenter im Browser aufgerufen wird (Origin-Prüfung gegen CSRF)
KC_BASE_URL=https://kundencenter.example.de
KC_API_PORT=4100
KC_NODE_ENV=development
# optional: SMTP (sonst wird nur im Mail-Protokoll abgelegt)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# optional: Discord (sonst bleibt der Bot deaktiviert)
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
DISCORD_ADMIN_CHANNEL_ID=
DISCORD_STAFF_USER_IDS= # kommagetrennt, dürfen /kc-Befehle nutzen
KC_NODE_ENV=production
# Kennung des Programms "Kundencenter" beim Lizenzsystem (kein Geheimnis, in jeder Installation gleich).
# Den eigenen Lizenzschlüssel trägt man danach in der Oberfläche ein (Einstellungen → Lizenz).
LICENSE_PROGRAM_KEY=da78e720-7180-4c44-84dd-50bfc83fe1ef

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ dist
!.env.example
*.log
*.tsbuildinfo
.pnpm-store/

18
LICENSE.md Normal file
View file

@ -0,0 +1,18 @@
# Nutzungsbedingungen KC@FlessingLabs
Copyright © 2026 FlessingLabs. Alle Rechte vorbehalten.
Der Quellcode von KC@FlessingLabs („Software“) ist öffentlich einsehbar. Er ist **keine** Open-Source-Software.
1. **Testmodus:** Die Software darf ohne Lizenz kostenlos installiert und im Testmodus betrieben werden
(1 Personal-Konto, 2 Kunden, ohne lizenzpflichtige Zusatzfunktionen).
2. **Lizenz:** Der Betrieb über die Grenzen des Testmodus hinaus und die Nutzung der lizenzpflichtigen Funktionen
(u. a. Discord-Bot, E-Mail-Posteingang, DATEV-Export, Backups auf externe Ziele) setzen eine gültige Lizenz von
FlessingLabs voraus. Umfang und Laufzeit ergeben sich aus der jeweiligen Lizenz.
3. **Nicht gestattet** sind das Umgehen, Entfernen oder Verändern der Lizenzprüfung sowie die Weitergabe, der Verkauf
oder das Anbieten der Software oder veränderter Fassungen als eigenes Produkt oder Dienst für Dritte.
4. **Eigene Anpassungen** für den Betrieb der eigenen Installation sind erlaubt.
5. **Gewährleistung:** Die Software wird ohne Gewähr bereitgestellt, soweit gesetzlich zulässig. Für Datensicherung
ist der Betreiber selbst verantwortlich.
Lizenzen und Fragen: https://flessinglabs.com

124
README.md
View file

@ -1,52 +1,106 @@
# Kundencenter
# KC@FlessingLabs – Kundencenter
Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, TypeScript).
Selbst gehostetes Kundencenter für Hosting-Anbieter, Agenturen und Software-Hersteller: Kunden, Produkte, Bestellungen,
Verträge, Rechnungen, Support-Tickets und Lizenzen in einer Oberfläche – mit Anbindung an KeyHelp und an das
FlessingLabs-Lizenzsystem. Modularer Monolith in TypeScript (Fastify, Next.js, MariaDB).
| Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe |
|---|---|---|---|
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Policy, Audit |
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot |
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, Proxy `/api/*` → API |
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Rechte, Audit |
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot, E-Mail-Posteingang |
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, leitet `/api/*` an die API weiter |
## Stand (Grundsystem)
Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen),
Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional).
Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`).
Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung).
Rechnungen (`docs/rechnungen.md`): Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF-Erzeugung, Firmenstammdaten unter Einstellungen → Firma.
Noch **nicht** vorhanden: Zahlungsanbieter-Anbindung, automatische wiederkehrende Rechnungsstellung, E-Mail-Versand von Rechnungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung).
## Funktionen
- **Konten und Sicherheit:** Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Einladungen, Rollen für Personal und Kunden, Audit-Protokoll mit Hash-Kette.
- **Kunden:** Privat- und Geschäftskunden, Rechnungsanschrift, Übernahme aus KeyHelp, Kundenansicht („Als Kunde ansehen“).
- **Produkte, Bestellungen, Verträge:** versionierte Produkte, Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Laufzeiten, Kündigung, automatische Verlängerung mit Erinnerung (`docs/produkte-bestellungen-vertraege.md`).
- **Bereitstellung über Connectoren:** KeyHelp (Hosting-Konten, Domains, Postfächer, Datenbanken) und Lizenzsystem (`docs/connector-vertrag.md`, `docs/connector-keyhelp.md`).
- **Lizenzverwaltung:** Übersicht, Vergabe mit Vertrag oder ohne Berechnung (Lizenz, Test, Add-on), Geräte freigeben, Sperren/Verlängern/Widerrufen, Limits und Funktionsumfang; Kunden sehen ihre Lizenzen und geben eigene Geräte frei.
- **Rechnungen:** Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF, DATEV-Export (`docs/rechnungen.md`).
- **Support:** Tickets je Kunde mit internen Notizen und Anhängen, E-Mail-Posteingang (IMAP), Discord-Bot mit eigenem Kanal je Ticket.
- **Betrieb:** verschlüsselte tägliche Backups mit automatischem Wiederherstellungstest (`docs/betrieb-backup-restore.md`).
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).
Sie werden in `modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
Neue Fähigkeiten (Connectoren, Rechnungen, Tickets) kommen als weitere Module hinzu.
## Testmodus und Lizenz
Ohne Lizenzschlüssel läuft das Kundencenter im **Testmodus**:
## Konfiguration
Geheimnisse liegen **nicht** im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600): `db.env`, `app.env`, `discord.env`, optional SMTP in `app.env`.
Vorlage: `.env.example`. Wichtig: `KC_BASE_URL` muss exakt der Browser-URL entsprechen (Origin-Prüfung gegen CSRF).
| | Testmodus | Mit Lizenz |
|---|---|---|
| Personal-Konten | 1 | laut Lizenz |
| Kunden | 2 | laut Lizenz |
| Discord-Bot, E-Mail-Posteingang (IMAP), DATEV-Export, Backups auf externe Ziele | – | ✓ |
Alles andere ist im Testmodus voll nutzbar. Eine Lizenz gibt es bei FlessingLabs (https://flessinglabs.com); den Schlüssel
unter **Einstellungen → Lizenz** eintragen, er wird regelmäßig bei licensing.flessinglabs.com geprüft (7 Tage offline
möglich). Läuft eine Lizenz ab, gilt wieder der Testmodus – bestehende Daten bleiben vollständig erhalten und nutzbar,
nur Neuanlagen über die Grenzen hinaus und die Zusatzfunktionen pausieren. Nutzungsbedingungen: `LICENSE.md`.
## Installation (Debian/Ubuntu)
Voraussetzungen: Node.js ≥ 22, pnpm 10, MariaDB ≥ 10.6, ein Reverse-Proxy mit HTTPS (Apache, nginx, Caddy …),
für Backups `gpg` und `rclone`.
```bash
# 1. Benutzer und Code
useradd --system --home-dir /var/lib/kundencenter --shell /usr/sbin/nologin kundencenter
git clone https://forge.flessinghome.de/flessinglabs/kundencenter.git /srv/kundencenter
chown -R kundencenter: /srv/kundencenter
cd /srv/kundencenter && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build
# 2. Datenbank (Zeichensatz utf8mb4 / utf8mb4_unicode_ci ist wichtig)
mysql -e "CREATE DATABASE kundencenter CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'kundencenter'@'localhost' IDENTIFIED BY '<passwort>';
GRANT ALL ON kundencenter.* TO 'kundencenter'@'localhost';
GRANT ALL ON kundencenter_restoretest.* TO 'kundencenter'@'localhost';"
# 3. Konfiguration (Vorlage: .env.example; Kommentare nie hinter einen Wert schreiben)
install -d -m 700 /etc/kundencenter
install -d -o kundencenter -m 755 /var/lib/kundencenter/requests
install -d -o kundencenter -m 750 /var/lib/kundencenter/ticket-attachments
install -d -m 700 /var/backups/kundencenter
# /etc/kundencenter/db.env und /etc/kundencenter/app.env anlegen, chmod 600
# 4. Schema und erster Superadmin (Passwort wird abgefragt)
cd /srv/kundencenter && pnpm migrate
pnpm cli create-superadmin --email=admin@example.de --name="Vorname Nachname"
# 5. Dienste
cp ops/systemd/kc-* /etc/systemd/system/ && systemctl daemon-reload
systemctl enable --now kc-api kc-web kc-worker kc-backup.timer kc-restore-test.timer kc-backup-request.path
```
Reverse-Proxy: alles auf `http://127.0.0.1:4101` weiterleiten und `X-Forwarded-Proto: https` setzen. Beispiel Apache:
```apache
ProxyPass / http://127.0.0.1:4101/
ProxyPassReverse / http://127.0.0.1:4101/
RequestHeader set X-Forwarded-Proto "https"
```
Danach anmelden, 2FA einrichten, unter **Einstellungen** Firma, E-Mail (SMTP) und Backup-Passwort hinterlegen.
## Update
```bash
cd /srv/kundencenter && systemctl start kc-backup # vorher sichern
sudo -u kundencenter git pull && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build
pnpm migrate && systemctl restart kc-api kc-web kc-worker
```
## Betrieb
```bash
systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always)
journalctl -u kc-api -f # Logs
pnpm install && pnpm build # Update: danach chown -R kundencenter und systemctl restart kc-*
pnpm migrate # Migrationen (migrations/*.sql, einmalig je Datei)
pnpm cli create-superadmin --email=… --name=… --password=…
pnpm cli reset-password --email=… # Passwort zurücksetzen
```
Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`.
## Tests
`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt).
Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette/Maskierung.
Geheimnisse liegen nie im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600). `KC_BASE_URL` muss exakt der Browser-Adresse entsprechen.
## Discord-Bot
In `/etc/kundencenter/discord.env` setzen: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_ADMIN_CHANNEL_ID`, `DISCORD_STAFF_USER_IDS` (kommagetrennt), dann `systemctl restart kc-worker`.
Befehle: `/kc-status`, `/kc-kunde suche:<Text>` (nur für freigegebene Discord-User, Antworten nur für sie sichtbar). In Kanäle gehen nur Ereignisse ohne personenbezogene Daten (z. B. „Neuer Kunde angelegt: K-10001“).
Unter **Einstellungen → Discord** Bot-Token, Server-ID und Kanäle eintragen (Anleitung auf der Seite). Mit einer
Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal, sichtbar nur für die Support-Rollen und den Kunden.
Kunden und Personal verknüpfen ihr Discord-Konto unter „Mein Konto“. Erfordert eine Lizenz.
## Backup / Restore (Datenbank)
```bash
set -a; . /etc/kundencenter/db.env; set +a
MYSQL_PWD=$DB_PASSWORD mysqldump -h127.0.0.1 -u$DB_USER --single-transaction --routines $DB_NAME | gzip > kundencenter-$(date +%F).sql.gz
gunzip -c kundencenter-DATUM.sql.gz | MYSQL_PWD=$DB_PASSWORD mysql -h127.0.0.1 -u$DB_USER $DB_NAME # Restore in leere DB
```
Automatisiertes, verschlüsseltes Backup mit Restore-Test ist noch offen (siehe Plane).
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`). Sie werden in
`modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
Neue Anbieter kommen als Connector unter `packages/connector-*` hinzu (`docs/connector-vertrag.md`).
## Tests
`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf
neu erstellt). Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette.

View file

@ -3,18 +3,65 @@ import { hash } from '@node-rs/argon2';
import '../core/config.js';
import { pool, one, run } from '../core/db.js';
import { audit } from '../core/audit.js';
import { assertStaffCapacity } from '../core/license.js';
const [cmd, ...args] = process.argv.slice(2);
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
/** Passwort ohne Echo abfragen (wie `passwd`): landet nie in argv, `ps aux` oder der Shell-History. */
async function promptHidden(question: string): Promise<string> {
process.stdout.write(question);
const stdin = process.stdin;
if (!stdin.isTTY) throw new Error('Kein interaktives Terminal verfügbar. Für nicht-interaktive Aufrufe stattdessen KC_CLI_PASSWORD setzen (Umgebungsvariable, nicht als Argument).');
return new Promise((resolve, reject) => {
stdin.resume(); stdin.setRawMode(true);
let input = '';
const onData = (chunk: Buffer) => {
const ch = chunk.toString('utf8');
if (ch === '\n' || ch === '\r' || ch === '\u0004') { cleanup(); process.stdout.write('\n'); resolve(input); }
else if (ch === '\u0003') { cleanup(); process.stdout.write('\n'); reject(new Error('Abgebrochen')); }
else if (ch === '\u007f' || ch === '\b') input = input.slice(0, -1);
else input += ch;
};
const cleanup = () => { stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData); };
stdin.on('data', onData);
});
}
/** Passwort holen: interaktiv (verdeckt) abfragen, oder – für Skripte/Automatisierung – aus KC_CLI_PASSWORD lesen.
* Nie als --password=…-Argument, das in `ps aux` für alle lokalen Nutzer sichtbar wäre. */
async function readPassword(): Promise<string> {
if (process.env.KC_CLI_PASSWORD) return process.env.KC_CLI_PASSWORD;
const pw = await promptHidden('Passwort (min. 12 Zeichen): ');
const repeat = await promptHidden('Passwort wiederholen: ');
if (pw !== repeat) throw new Error('Passwörter stimmen nicht überein');
return pw;
}
if (cmd === 'create-superadmin') {
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password');
if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password=<min. 12 Zeichen>'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); }
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin';
if (!email) { console.error('Nutzung: create-superadmin --email=… [--name=…] (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits (für einen Reset stattdessen: reset-password --email=…)'); process.exit(1); }
const cap = await assertStaffCapacity();
if (!cap.allowed) { console.error(cap.reason); process.exit(1); }
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
const id = randomUUID();
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]);
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, ARGON)]);
await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } });
console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)');
} else if (cmd === 'reset-password') {
// Fallback für den Fall, dass die Selbstbedienung (E-Mail-Link unter /passwort-vergessen) nicht erreichbar ist
// (z. B. Postfach ebenfalls verloren, oder SMTP noch nicht eingerichtet). Funktioniert für jeden Nutzer, nicht nur Staff.
const email = opt('email')?.toLowerCase();
if (!email) { console.error('Nutzung: reset-password --email=… (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
const u = await one('SELECT id FROM users WHERE email = ?', [email]);
if (!u) { console.error('Kein Nutzer mit dieser E-Mail'); process.exit(1); }
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(pw, ARGON), u.id]);
await audit({ actorType: 'system', action: 'auth.password.reset', resourceType: 'user', resourceId: u.id, after: { via: 'cli' } });
console.log('Passwort zurückgesetzt für', email, '- bestehende Sitzungen bleiben aktiv (nicht automatisch abgemeldet)');
} else if (cmd === 'connector-secrets') {
// Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs)
// Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password)
@ -49,8 +96,15 @@ if (cmd === 'create-superadmin') {
else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; }
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
} else if (cmd === 'audit-purge') {
// Löscht Audit-Einträge jenseits der konfigurierten Aufbewahrungsfrist (auch: audit_settings.retention_days).
// Läuft sonst täglich automatisch im Worker; hier v. a. zum manuellen Testen/Nachvollziehen.
const { purgeAuditRetention } = await import('../core/audit.js');
const days = opt('days') ? Number(opt('days')) : (await one('SELECT retention_days FROM audit_settings WHERE id = 1'))?.retention_days ?? 180;
const r = await purgeAuditRetention(days);
console.log(JSON.stringify(r, null, 2));
} else {
console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup');
console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup, audit-purge');
process.exit(2);
}
await pool.end();

View file

@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, dirname } from 'node:path';
@ -5,19 +6,43 @@ import '../core/config.js';
import { pool } from '../core/db.js';
const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations');
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export async function migrate(): Promise<void> {
await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name));
await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, checksum CHAR(64) NULL, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
await pool.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum CHAR(64) NULL'); // bestehende Installationen ohne die Spalte nachziehen
// Sperre gegen gleichzeitige Migrationsläufe (z. B. zwei parallele Deploys) – sonst könnten beide dieselbe neue
// Migration anwenden oder sich beim Anlegen der Zeile in schema_migrations in die Quere kommen.
const [[lockRow]] = await pool.query('SELECT GET_LOCK(?, 10) AS got', ['kc_migrate']) as any;
if (Number(lockRow?.got) !== 1) throw new Error('Migrations-Sperre konnte nicht erlangt werden (läuft bereits eine andere Migration? Zeitüberschreitung nach 10s)');
try {
const rows = (await pool.query('SELECT name, checksum FROM schema_migrations') as any)[0] as { name: string; checksum: string | null }[];
const applied = new Map(rows.map((r) => [r.name, r.checksum]));
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
if (done.has(f)) continue;
const content = readFileSync(join(dir, f), 'utf8');
const sum = sha256(content);
if (applied.has(f)) {
const stored = applied.get(f);
// Bereits angewendete Migrationen dürfen nachträglich nicht mehr verändert werden (sonst driften Umgebungen
// unbemerkt auseinander, je nachdem wann sie migriert haben). Alte Zeilen ohne Prüfsumme werden einmalig nachgetragen.
if (stored == null) await pool.query('UPDATE schema_migrations SET checksum = ? WHERE name = ?', [sum, f]);
else if (stored !== sum) throw new Error(`Migration ${f} wurde bereits angewendet, ihr Inhalt hat sich seitdem aber geändert (Prüfsumme weicht ab). Bitte die Datei nicht nachträglich bearbeiten, sondern eine neue Migration anlegen.`);
continue;
}
const c = await pool.getConnection();
try {
// DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch.
const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
const stmts = content.replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
for (const st of stmts) await c.query(st);
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
await c.query('INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)', [f, sum]);
console.log('migriert:', f);
} finally { c.release(); }
}
} finally {
await pool.query('SELECT RELEASE_LOCK(?)', ['kc_migrate']).catch(() => undefined);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try { await migrate(); } catch (e) { console.error((e as Error).message); await pool.end(); process.exit(1); }
await pool.end();
}
if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); }

View file

@ -4,12 +4,15 @@ import { one, query, run } from './db.js';
import { config } from './config.js';
import { randomToken, sha256, safeEqual } from './crypto.js';
import { forbidden, unauthorized } from './errors.js';
import { audit } from './audit.js';
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
export const COOKIE = 'kc_session';
const SESSION_HOURS = 12;
const IDLE_MINUTES = 120;
export const IMPERSONATION_MINUTES = 60;
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
export interface AuthContext {
sessionId: string;
csrf: string;
@ -17,6 +20,10 @@ export interface AuthContext {
mfaEnrolled: boolean;
user: { id: string; email: string; name: string };
principal: Principal;
/** Gesetzt, während ein Staff-Mitglied "als Kunde" unterwegs ist (siehe customers-Modul, /admin/customers/:id/impersonate).
* principal ist in diesem Fall absichtlich auf 'customer' mit Mitgliedschaft in genau dieser Organisation umgeschaltet -
* echte Rechteeinschränkung, nicht nur eine andere Oberfläche. user bleibt die echte Staff-Identität (fürs Audit). */
impersonating: Impersonating | null;
}
declare module 'fastify' {
interface FastifyRequest { auth?: AuthContext; correlationId: string }
@ -39,7 +46,8 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
const token = req.cookies[COOKIE];
if (!token) return undefined;
const s = await one(
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
`SELECT s.id, s.csrf_token, s.pending_mfa, s.impersonating_org_id, s.impersonation_reason, s.impersonation_started_at, s.impersonation_expires_at,
u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
@ -47,12 +55,30 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
[sha256(token), IDLE_MINUTES],
);
if (!s || s.status !== 'active') return undefined;
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
let principal: Principal = { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: [] };
let impersonating: Impersonating | null = null;
if (s.impersonating_org_id) {
if (new Date(s.impersonation_expires_at as Date) <= new Date()) {
// Abgelaufen: Sitzung fällt automatisch auf die echte Staff-Identität zurück, kein harter Logout.
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [s.id]);
await audit({ actorType: 'user', actorId: s.uid, orgId: s.impersonating_org_id, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: s.impersonating_org_id, after: { reason: 'expired' } }).catch(() => undefined);
} else {
const org = await one('SELECT name, customer_number FROM organizations WHERE id = ?', [s.impersonating_org_id]);
if (org) {
principal = { userId: s.uid, kind: 'customer', staffRole: null, memberships: [{ orgId: s.impersonating_org_id, role: 'owner' }] };
impersonating = { orgId: s.impersonating_org_id, orgName: org.name, customerNumber: org.customer_number, reason: s.impersonation_reason, startedAt: s.impersonation_started_at, expiresAt: s.impersonation_expires_at };
}
}
}
if (!impersonating) {
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
principal.memberships = ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole }));
}
return {
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
user: { id: s.uid, email: s.email, name: s.name },
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
user: { id: s.uid, email: s.email, name: s.name }, principal, impersonating,
};
}

View file

@ -0,0 +1 @@
export * from '@kc/platform/license';

View file

@ -1,10 +1,18 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { query } from '../../core/db.js';
import { verifyAuditChain } from '../../core/audit.js';
import { requirePermission } from '../../core/auth.js';
import { one, query, run } from '../../core/db.js';
import { audit, verifyAuditChain, purgeAuditRetention } from '../../core/audit.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const AUDIT_SQL = 'SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json FROM audit_events';
const listQuery = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), from: z.string().date().optional(), to: z.string().date().optional() });
// Formel-Injection-Schutz wie im Rechnungs-CSV-Export (apps/api/src/modules/invoices/index.ts) - hier lokal
// dupliziert statt importiert, damit Module weiterhin ohne Querverweise aufeinander auskommen.
const csvSafe = (s: string) => (/^[=+\-@\t]/.test(s) ? `'${s}` : s);
const csvCell = (s: string) => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; };
export const auditModule: KcModule = {
name: 'audit',
register(app: FastifyInstance) {
@ -12,8 +20,7 @@ export const auditModule: KcModule = {
requirePermission(req, 'audit.read');
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
const rows = await query(
`SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
`${AUDIT_SQL} WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
ORDER BY id DESC LIMIT ?`,
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
);
@ -23,5 +30,58 @@ export const auditModule: KcModule = {
requirePermission(req, 'audit.read');
return verifyAuditChain();
});
// ---- Aufbewahrungs-Policy (Default reflektiert den DSGVO-Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1
// lit. e - die DSGVO selbst schreibt dafür keine feste Zahl vor; änderbar je nach eigener Löschrichtlinie) ---
app.get('/admin/audit/settings', async (req) => {
requirePermission(req, 'audit.read');
const s = await one('SELECT retention_days, updated_at FROM audit_settings WHERE id = 1');
const last = await one('SELECT purged_at, purged_count, purged_through_id, retention_days FROM audit_retention_checkpoints ORDER BY id DESC LIMIT 1');
return {
retentionDays: s?.retention_days ?? 180, updatedAt: s?.updated_at ?? null,
lastPurge: last ? { at: last.purged_at, count: last.purged_count, throughId: last.purged_through_id, retentionDays: last.retention_days } : null,
};
});
app.put('/admin/audit/settings', async (req) => {
const a = requirePermission(req, 'settings.write');
const b = z.object({ retentionDays: z.number().int().min(30).max(3650) }).parse(req.body);
await run('UPDATE audit_settings SET retention_days = ? WHERE id = 1', [b.retentionDays]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.settings.update', resourceType: 'audit_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { retentionDays: b.retentionDays } });
return { ok: true };
});
// Manuelles Anstoßen (der Worker macht das sonst täglich); v. a. zum sofortigen Nachvollziehen nach einer
// Änderung der Frist gedacht, nicht für den Alltag.
app.post('/admin/audit/purge', async (req) => {
const a = requirePermission(req, 'settings.write');
const s = await one('SELECT retention_days FROM audit_settings WHERE id = 1');
const res = await purgeAuditRetention(s?.retention_days ?? 180).catch((e: unknown) => { throw badRequest((e as Error).message, 'PURGE_FAILED'); });
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.purge.manual', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: res });
return res;
});
/** CSV-Export (für Auskunftsersuchen/Nachweis vor einer geplanten Aufräumung). before/after sind bereits
* beim Schreiben maskiert (siehe mask() in @kc/platform/audit) - hier keine zusätzliche Maskierung nötig. */
app.get('/admin/audit/export.csv', async (req, reply) => {
const a = requirePermission(req, 'audit.read');
const q = listQuery.parse(req.query);
const conds: string[] = ['1=1']; const params: unknown[] = [];
if (q.action) { conds.push('action LIKE CONCAT(?, ?)'); params.push(q.action, '%'); }
if (q.actor) { conds.push('actor_id = ?'); params.push(q.actor); }
if (q.org) { conds.push('org_id = ?'); params.push(q.org); }
if (q.from) { conds.push('ts >= ?'); params.push(q.from); }
if (q.to) { conds.push('ts <= DATE_ADD(?, INTERVAL 1 DAY)'); params.push(q.to); }
const rows = await query(`${AUDIT_SQL} WHERE ${conds.join(' AND ')} ORDER BY id LIMIT 50000`, params);
const header = ['ID', 'Zeit', 'Akteur-Typ', 'Akteur-ID', 'Organisation-ID', 'Aktion', 'Objekt-Typ', 'Objekt-ID', 'Ergebnis', 'Fehlerklasse', 'Korrelations-ID', 'IP', 'Vorher', 'Nachher'];
const lines = [header.join(';')];
for (const r of rows) {
lines.push([
String(r.id), (r.ts as Date).toISOString(), r.actor_type, r.actor_id ?? '', r.org_id ?? '', csvCell(r.action), r.resource_type ?? '', r.resource_id ?? '',
r.result, r.error_class ?? '', r.correlation_id ?? '', r.ip ?? '', csvCell(r.before_json ? JSON.stringify(r.before_json) : ''), csvCell(r.after_json ? JSON.stringify(r.after_json) : ''),
].join(';'));
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.export_csv', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } });
reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="audit-export-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`);
return reply.send('' + lines.join('\r\n') + '\r\n');
});
},
};

View file

@ -13,6 +13,7 @@ import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '
import { loadTargets, testTarget } from '../../ops/targets.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import type { KcModule } from '../../core/module.js';
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
@ -111,6 +112,7 @@ export const backupModule: KcModule = {
app.post('/admin/backup/targets', async (req) => {
const a = requirePermission(req, 'backup.secrets');
if (!(await hasFeature('backup_remote'))) throw forbidden(featureRequiresLicense('backup_remote'), 'LICENSE_REQUIRED');
const b = targetSchema.parse(req.body);
if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
const { cfg, sec, path } = split(b); const id = randomUUID();

View file

@ -11,7 +11,7 @@ import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const int = (max: number) => z.number().int().min(0).max(max);
@ -272,7 +272,9 @@ export const catalogModule: KcModule = {
const q = z.object({ org: z.string().uuid() }).parse(req.query);
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
// Personal darf auch Produkte bestellen, die Kunden nicht selbst bestellen können (siehe POST /orders)
const staff = can(a.principal, 'orders.write');
const rows = await query(`${productSelect} WHERE p.status = 'active'${staff ? '' : ' AND p.orderable_by_customer = 1'} ORDER BY v.name`);
return Promise.all(rows.map(async (r) => {
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
// Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises.
@ -280,7 +282,7 @@ export const catalogModule: KcModule = {
termMonths: t.term_months,
price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring,
}));
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers };
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, orderableByCustomer: !!r.orderable_by_customer, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers };
}));
});
},

View file

@ -3,8 +3,9 @@ import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { clientIp, requireAuth, requirePermission, IMPERSONATION_MINUTES } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { assertCustomerCapacity } from '../../core/license.js';
import { canInOrg } from '../../core/policy.js';
import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js';
import { enqueue } from '../../core/jobs.js';
@ -59,6 +60,9 @@ async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string>
export const customersModule: KcModule = {
name: 'customers',
permissions: {
staff: { admin: ['customers.impersonate'], superadmin: ['customers.impersonate'] },
},
register(app: FastifyInstance) {
// ---- Admin: Kunden -----------------------------------------------------
app.get('/admin/customers', async (req) => {
@ -84,6 +88,8 @@ export const customersModule: KcModule = {
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
const orgId = randomUUID();
const res = await tx(async (c) => {
const cap = await assertCustomerCapacity(c);
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
const number = await nextCustomerNumber(c);
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
const bp = b.billing;
@ -105,6 +111,20 @@ export const customersModule: KcModule = {
return loadOrg(id);
});
/** "Als Kunde ansehen": schaltet die eigene Sitzung zeitlich begrenzt (IMPERSONATION_MINUTES) auf die
* Sicht dieses Kunden um (echte Rechteeinschränkung, nicht nur eine andere Oberfläche). Admin/Superadmin
* only; Grund ist Pflicht und wird protokolliert. Lässt sich nicht verschachteln (erfordert customers.read,
* das während einer laufenden Impersonation nicht mehr greift). */
app.post('/admin/customers/:id/impersonate', async (req) => {
const a = requirePermission(req, 'customers.impersonate');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ reason: z.string().trim().min(3).max(300) }).parse(req.body);
const org = await one('SELECT id, name, customer_number FROM organizations WHERE id = ?', [id]); if (!org) throw notFound();
await run('UPDATE sessions SET impersonating_org_id = ?, impersonation_reason = ?, impersonation_started_at = UTC_TIMESTAMP(3), impersonation_expires_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE) WHERE id = ?', [id, b.reason, IMPERSONATION_MINUTES, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.impersonate.start', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason, minutes: IMPERSONATION_MINUTES } });
return { ok: true, orgName: org.name, customerNumber: org.customer_number, expiresInMinutes: IMPERSONATION_MINUTES };
});
/** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */
app.post('/admin/customers/:id/welcome-mail', async (req) => {
const a = requirePermission(req, 'customers.write');
@ -145,6 +165,10 @@ export const customersModule: KcModule = {
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
await tx(async (c) => {
if (b.status === 'active' && before.status === 'closed') {
const cap = await assertCustomerCapacity(c);
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
}
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
const keys = Object.keys(patch);
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
@ -221,6 +245,8 @@ export const customersModule: KcModule = {
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
} else {
const cap = await assertCustomerCapacity(cn);
if (!cap.allowed) throw badRequest(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }

View file

@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js';
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { rl, config } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
@ -12,6 +13,7 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id,
ticketCategoryId: s.ticket_category_id, ticketLogChannelId: s.ticket_log_channel_id, supportRoleIds: s.support_role_ids,
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
});
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
@ -30,11 +32,18 @@ export const discordModule: KcModule = {
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
ticketChannelId: z.string().trim().regex(ID).nullable(),
// optional, damit ältere Formulare die Werte nicht versehentlich löschen
ticketCategoryId: z.string().trim().regex(ID).nullable().optional(), ticketLogChannelId: z.string().trim().regex(ID).nullable().optional(),
supportRoleIds: z.string().trim().regex(/^\d{15,25}(\s*,\s*\d{15,25})*$/).max(500).nullable().optional(),
enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body);
if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); }
if (b.ticketLogChannelId !== undefined) { sets.push('ticket_log_channel_id = ?'); params.push(b.ticketLogChannelId); }
if (b.supportRoleIds !== undefined) { sets.push('support_role_ids = ?'); params.push(b.supportRoleIds ? b.supportRoleIds.split(',').map((x) => x.trim()).join(',') : null); }
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); }
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
@ -96,6 +105,7 @@ export const discordModule: KcModule = {
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
app.get('/discord/oauth/start', async (req, reply) => {
const a = requireAuth(req);
if (!(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
const state = randomToken(32);

View file

@ -20,7 +20,7 @@ async function snapshot(tld: string) {
}
const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months,
costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents,
procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
procurement: r.procurement, orderedAt: r.ordered_at, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id';
const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info'];
const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen');
@ -87,10 +87,10 @@ export const domainsModule: KcModule = {
});
app.patch('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound();
if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]);
if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]);
if (b.orderedAt) await run("UPDATE domain_records SET ordered_at = ?, procurement = IF(procurement = 'open', 'ordered', procurement) WHERE id = ?", [`${b.orderedAt} 00:00:00`, id]);
if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]);
if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]);
if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); }

View file

@ -8,6 +8,7 @@ import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js';
import { assertStaffCapacity } from '../../core/license.js';
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
import { can, staffPermissions } from '../../core/policy.js';
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
@ -93,16 +94,30 @@ export const identityModule: KcModule = {
app.get('/auth/me', async (req) => {
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
const orgs = a.principal.memberships.length
// Während einer Impersonation stammt die Mitgliedschaft nicht aus der echten memberships-Tabelle
// (der Staff-Nutzer ist dort nicht wirklich Mitglied) - direkt aus dem Impersonation-Kontext aufbauen.
const orgs = a.impersonating
? [{ id: a.impersonating.orgId, name: a.impersonating.orgName, customer_number: a.impersonating.customerNumber, role: 'owner' }]
: a.principal.memberships.length
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
: [];
return {
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
impersonating: a.impersonating,
};
});
/** Beendet die eigene Impersonation-Sitzung (siehe POST /admin/customers/:id/impersonate), egal ob abgelaufen oder nicht. */
app.post('/auth/impersonate/stop', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (!a.impersonating) return { ok: true };
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: a.impersonating.orgId, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: a.impersonating.orgId, correlationId: req.correlationId, ip: clientIp(req), after: { reason: 'manual' } });
return { ok: true };
});
// ---- MFA-Einrichtung ---------------------------------------------------
app.post('/auth/mfa/setup', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
@ -225,6 +240,8 @@ export const identityModule: KcModule = {
const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body);
if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS');
const cap = await assertStaffCapacity();
if (!cap.allowed) throw forbidden(cap.reason!, 'STAFF_LIMIT_REACHED');
const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole }));
const mail = await mailInvite(b.email, b.name, inv.token);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } });

View file

@ -13,6 +13,8 @@ import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/index.js';
import { mailModule } from './mail/index.js';
import { discordModule } from './discord/index.js';
import { licenseModule } from './license/index.js';
import { licensingModule } from './licensing/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule];
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule, licensingModule];

View file

@ -1,6 +1,10 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { one, query, run, tx } from '../../core/db.js';
@ -8,6 +12,7 @@ import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
import { config } from '../../core/config.js';
@ -37,6 +42,21 @@ async function settings(): Promise<CompanySettings> {
}
const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId));
export interface DatevSettings {
beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string;
erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null;
}
async function datevSettings(): Promise<DatevSettings> {
const s = await one('SELECT * FROM company_settings WHERE id = 1');
return {
beraterNr: s?.datev_berater_nr ?? null, mandantNr: s?.datev_mandant_nr ?? null, skr: s?.datev_skr ?? null,
sachkontenlaenge: Number(s?.datev_sachkontenlaenge ?? 4), fiscalYearStart: s?.datev_fiscal_year_start ?? '01-01',
erloeskonto19: s?.datev_erloeskonto_19 ?? null, erloeskonto7: s?.datev_erloeskonto_7 ?? null, erloeskonto0: s?.datev_erloeskonto_0 ?? null,
diktatkuerzel: s?.datev_diktatkuerzel ?? null,
};
}
const datevComplete = (d: DatevSettings) => !!(d.beraterNr && d.mandantNr);
const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents });
const invoiceView = (v: any) => ({
id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status,
@ -57,6 +77,110 @@ const notify = (event: string, extra: Record<string, unknown>, key: string, corr
const mailTo = (to: string, template: string, vars: Record<string, string>, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId });
const deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE');
const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x);
// ---- Export für den Steuerberater (Rechnungsjournal als CSV, Belege als ZIP) ------------------
const exportQuery = z.object({
from: z.string().date().optional(), to: z.string().date().optional(),
status: z.enum(['open', 'paid', 'cancelled']).optional(), org: z.string().uuid().optional(),
});
/** Nie Entwürfe (keine steuerlich relevanten Belege); weitere Filter optional. */
function exportFilter(q: z.infer<typeof exportQuery>): { sql: string; params: unknown[] } {
const conds: string[] = ["v.status != 'draft'"]; const params: unknown[] = [];
if (q.from) { conds.push('v.issue_date >= ?'); params.push(q.from); }
if (q.to) { conds.push('v.issue_date <= ?'); params.push(q.to); }
if (q.status) { conds.push('v.status = ?'); params.push(q.status); }
if (q.org) { conds.push('v.org_id = ?'); params.push(q.org); }
return { sql: conds.join(' AND '), params };
}
const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ',');
// Formel-Injection-Schutz (CSV): Zellen, die mit =, +, -, @ oder Tab beginnen, werden von Excel/Sheets sonst als Formel
// ausgeführt. Firmenname/Notiz stammen aus vom Kunden bzw. Personal eingegebenen Feldern – daher immer entschärfen.
const csvSafe = (s: string): string => (/^[=+\-@\t]/.test(s) ? `'${s}` : s);
const csvCell = (s: string): string => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; };
const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : '');
const STATUS_LABEL: Record<string, string> = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' };
// ---- DATEV-Buchungsstapel-Export (EXTF, Format 700/21) -----------------------------------------
// Feldlisten und Reihenfolge gegen die offizielle DATEV-Formatbeschreibung sowie ein reales Beispiel
// aus der etablierten Open-Source-Bibliothek github.com/ledermann/datev verifiziert (nicht aus dem
// Gedächtnis geraten), da ein falsches Spaltenlayout die Datei beim Steuerberater unbrauchbar macht.
const DATEV_BOOKING_HEADER = [
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs', 'Basisumsatz', 'WKZ Basisumsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum',
'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext', 'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt', 'Zinssperre', 'Beleglink',
'Beleginfo – Art 1', 'Beleginfo – Inhalt 1', 'Beleginfo – Art 2', 'Beleginfo – Inhalt 2', 'Beleginfo – Art 3', 'Beleginfo – Inhalt 3', 'Beleginfo – Art 4', 'Beleginfo – Inhalt 4',
'Beleginfo – Art 5', 'Beleginfo – Inhalt 5', 'Beleginfo – Art 6', 'Beleginfo – Inhalt 6', 'Beleginfo – Art 7', 'Beleginfo – Inhalt 7', 'Beleginfo – Art 8', 'Beleginfo – Inhalt 8',
'KOST1 – Kostenstelle', 'KOST2 – Kostenstelle', 'Kost Menge', 'EU-Land u. USt-IdNr.', 'EU-Steuersatz', 'Abw. Versteuerungsart', 'Sachverhalt L+L', 'Funktionsergänzung L+L',
'BU 49 Hauptfunktionstyp', 'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung',
'Zusatzinformation – Art 1', 'Zusatzinformation – Inhalt 1', 'Zusatzinformation – Art 2', 'Zusatzinformation – Inhalt 2', 'Zusatzinformation – Art 3', 'Zusatzinformation – Inhalt 3',
'Zusatzinformation – Art 4', 'Zusatzinformation – Inhalt 4', 'Zusatzinformation – Art 5', 'Zusatzinformation – Inhalt 5', 'Zusatzinformation – Art 6', 'Zusatzinformation – Inhalt 6',
'Zusatzinformation – Art 7', 'Zusatzinformation – Inhalt 7', 'Zusatzinformation – Art 8', 'Zusatzinformation – Inhalt 8', 'Zusatzinformation – Art 9', 'Zusatzinformation – Inhalt 9',
'Zusatzinformation – Art 10', 'Zusatzinformation – Inhalt 10', 'Zusatzinformation – Art 11', 'Zusatzinformation – Inhalt 11', 'Zusatzinformation – Art 12', 'Zusatzinformation – Inhalt 12',
'Zusatzinformation – Art 13', 'Zusatzinformation – Inhalt 13', 'Zusatzinformation – Art 14', 'Zusatzinformation – Inhalt 14', 'Zusatzinformation – Art 15', 'Zusatzinformation – Inhalt 15',
'Zusatzinformation – Art 16', 'Zusatzinformation – Inhalt 16', 'Zusatzinformation – Art 17', 'Zusatzinformation – Inhalt 17', 'Zusatzinformation – Art 18', 'Zusatzinformation – Inhalt 18',
'Zusatzinformation – Art 19', 'Zusatzinformation – Inhalt 19', 'Zusatzinformation – Art 20', 'Zusatzinformation – Inhalt 20',
'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr', 'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp', 'USt-Schlüssel (Anzahlungen)',
'EU-Mitgliedstaat (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)', 'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz', 'Leerfeld', 'KOST-Datum', 'SEPA-Mandatsreferenz',
'Skontosperre', 'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer', 'Postensperre bis', 'Bezeichnung', 'Kennzeichen', 'Festschreibung', 'Leistungsdatum',
'Datum Zuord.', 'Fälligkeit', 'Generalumkehr', 'Steuersatz', 'Land', 'Abrechnungsreferent', 'BVV-Position', 'EU-Mitgliedstaat u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto',
]; // 125 Felder
const pad2 = (n: number) => String(n).padStart(2, '0');
const datevYmd = (d: Date) => `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`;
const datevDm = (d: Date) => `${pad2(d.getUTCDate())}${pad2(d.getUTCMonth() + 1)}`;
const datevQ = (s: string) => `"${csvSafe(s).replace(/"/g, '""')}"`;
const datevNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ',');
/** Eine Buchungszeile mit genau 125 Feldern; nur die übergebenen (1-basierten) Spaltennummern werden gesetzt, der Rest bleibt leer. */
function datevRow(vals: Record<number, string>): string {
const arr = new Array(DATEV_BOOKING_HEADER.length).fill('');
for (const [idx, val] of Object.entries(vals)) arr[Number(idx) - 1] = val;
return arr.join(';');
}
// Zeichen außerhalb 0x00–0xFF (z. B. der Halbgeviertstrich – in den DATEV-Spaltennamen), die es in ISO-8859-1
// nicht gibt, aber im von DATEV traditionell erwarteten Windows-1252 auf einem eigenen Byte liegen.
const WIN1252_HIGH: Record<number, number> = {
0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e,
0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f,
};
/** Node kennt kein natives Windows-1252 (nur strikt ISO-8859-1 als "latin1"); Zeichen >0xFF würden sonst stillschweigend verstümmelt. */
function toWin1252(str: string): Buffer {
const bytes: number[] = [];
for (const ch of str) { const cp = ch.codePointAt(0)!; bytes.push(cp <= 0xff ? cp : (WIN1252_HIGH[cp] ?? 0x3f)); }
return Buffer.from(bytes);
}
/** Absender-/Empfängerdaten und Positionen für das PDF – mit demselben Rückfall wie /invoices/:id/pdf
* für Rechnungen, die vor der Absender-Einfrierung (Migration 020) ausgestellt wurden. */
async function pdfInput(v: Record<string, any>): Promise<{ inv: InvoiceForPdf; seller: CompanySettings }> {
const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [v.id]);
let seller = jparse(v.seller_snapshot_json) as CompanySettings | null;
let customer = jparse(v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null;
if (!seller || !customer) {
const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [v.org_id]);
seller ??= await settings();
customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null };
}
const inv: InvoiceForPdf = {
number: v.number, issueDate: v.issue_date, dueDate: v.due_date, status: v.status, paymentMethod: v.payment_method, note: v.note,
totalNetCents: v.total_net_cents, totalTaxCents: v.total_tax_cents, totalGrossCents: v.total_gross_cents,
customer, items: items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })),
};
return { inv, seller };
}
function streamToBuffer(s: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
s.on('data', (c) => chunks.push(c)); s.on('end', () => resolve(Buffer.concat(chunks))); s.on('error', reject);
});
}
/** Externes Programm ohne Shell starten (wie in ops/backup.ts, hier lokal, um dort keine Export-Belange zu vermischen). */
function runBin(cmd: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
let err = ''; p.stderr!.on('data', (d) => { err += d; });
p.on('error', (e) => reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`)));
p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().slice(-500)}`))));
});
}
export const invoicesModule: KcModule = {
name: 'invoices',
@ -66,7 +190,11 @@ export const invoicesModule: KcModule = {
},
register(app: FastifyInstance) {
// ---- Firmenstammdaten (für den Rechnungskopf) ---------------------------
app.get('/admin/company-settings', async (req) => { requirePermission(req, 'invoices.read'); const s = await settings(); return { ...s, complete: complete(s) }; });
app.get('/admin/company-settings', async (req) => {
requirePermission(req, 'invoices.read');
const s = await settings(); const d = await datevSettings();
return { ...s, complete: complete(s), datev: { ...d, complete: datevComplete(d) } };
});
app.put('/admin/company-settings', async (req) => {
const a = requirePermission(req, 'settings.write');
const b = z.object({
@ -74,8 +202,17 @@ export const invoicesModule: KcModule = {
taxNumber: z.string().trim().max(50).optional(), vatId: z.string().trim().max(30).optional(), bankName: z.string().trim().max(150).optional(), iban: z.string().trim().max(34).optional(), bic: z.string().trim().max(11).optional(),
invoicePrefix: z.string().trim().regex(/^[A-Za-z0-9]{1,10}$/).optional(), defaultDueDays: z.number().int().min(0).max(180).optional(),
paymentMethods: z.array(z.string().trim().min(1).max(50)).min(1).max(10).optional(), footerText: z.string().trim().max(500).nullable().optional(),
datevBeraterNr: z.number().int().min(1001).max(9999999).nullable().optional(), datevMandantNr: z.number().int().min(1).max(99999).nullable().optional(),
datevSkr: z.enum(['03', '04']).nullable().optional(), datevSachkontenlaenge: z.number().int().min(4).max(8).optional(),
datevFiscalYearStart: z.string().regex(/^\d{2}-\d{2}$/).optional(),
datevErloeskonto19: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto7: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto0: z.number().int().min(0).max(999999999).nullable().optional(),
datevDiktatkuerzel: z.string().trim().max(2).nullable().optional(),
}).parse(req.body);
const cols: Record<string, string> = { name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text' };
const cols: Record<string, string> = {
name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text',
datevBeraterNr: 'datev_berater_nr', datevMandantNr: 'datev_mandant_nr', datevSkr: 'datev_skr', datevSachkontenlaenge: 'datev_sachkontenlaenge', datevFiscalYearStart: 'datev_fiscal_year_start',
datevErloeskonto19: 'datev_erloeskonto_19', datevErloeskonto7: 'datev_erloeskonto_7', datevErloeskonto0: 'datev_erloeskonto_0', datevDiktatkuerzel: 'datev_diktatkuerzel',
};
const sets: string[] = []; const params: unknown[] = [];
for (const [k, col] of Object.entries(cols)) if ((b as Record<string, unknown>)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record<string, unknown>)[k]); }
if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); }
@ -285,21 +422,119 @@ export const invoicesModule: KcModule = {
const res = await loadInvoice(id);
if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound();
if (res.v.status === 'draft') throw badRequest('Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.', 'INVOICE_DRAFT');
const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x);
let seller = jparse(res.v.seller_snapshot_json) as CompanySettings | null;
let customer = jparse(res.v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null;
if (!seller || !customer) { // vor Migration 020 ausgestellt: kein eingefrorener Stand vorhanden, Rückfall auf die damals übliche Live-Anzeige
const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]);
seller ??= await settings();
customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null };
}
const inv: InvoiceForPdf = {
number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note,
totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents,
customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })),
};
const { inv, seller } = await pdfInput(res.v);
reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`);
return reply.send(renderInvoicePdf(inv, seller));
});
/** Rechnungsjournal als CSV (Semikolon, Dezimalkomma, UTF-8-BOM – öffnet sich in Excel ohne Umweg).
* Für den Steuerberater/die Buchhaltung: eine Zeile je ausgestellter Rechnung/Stornorechnung, nie Entwürfe. */
app.get('/admin/invoices/export.csv', async (req, reply) => {
const a = requirePermission(req, 'invoices.read');
const q = exportQuery.parse(req.query);
const { sql, params } = exportFilter(q);
const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params);
const numberOf = new Map<string, string>(rows.map((v) => [v.id as string, v.number as string]));
const missing = [...new Set(rows.flatMap((v) => [v.cancels_invoice_id, v.cancelled_by_invoice_id]).filter((id): id is string => !!id && !numberOf.has(id)))];
if (missing.length) for (const r of await query(`SELECT id, number FROM invoices WHERE id IN (${missing.map(() => '?').join(',')})`, missing)) numberOf.set(r.id, r.number);
const header = ['Rechnungsnummer', 'Rechnungsdatum', 'Faelligkeitsdatum', 'Kundennummer', 'Kunde', 'Land', 'USt-IdNr', 'Netto', 'USt effektiv %', 'USt-Betrag', 'Brutto', 'Waehrung', 'Status', 'Bezahlt am', 'Zahlungsart', 'Storno zu', 'Storniert durch', 'Notiz'];
const lines = [header.join(';')];
for (const v of rows) {
const buyer = jparse(v.buyer_snapshot_json) as { name?: string; country?: string; vatId?: string } | null;
const rate = v.total_net_cents !== 0 ? Math.round((v.total_tax_cents / v.total_net_cents) * 10000) / 100 : 0;
lines.push([
v.number ?? '', csvDate(v.issue_date), csvDate(v.due_date), v.customer_number ?? '', csvCell(buyer?.name || v.org_name || ''),
buyer?.country ?? 'DE', buyer?.vatId ?? '', csvNum(v.total_net_cents), rate.toString().replace('.', ','), csvNum(v.total_tax_cents), csvNum(v.total_gross_cents),
v.currency ?? 'EUR', STATUS_LABEL[v.status] ?? v.status, v.paid_at ? csvDate(v.paid_at) : '', v.payment_method ?? '',
v.cancels_invoice_id ? (numberOf.get(v.cancels_invoice_id) ?? '') : '', v.cancelled_by_invoice_id ? (numberOf.get(v.cancelled_by_invoice_id) ?? '') : '', csvCell(v.note ?? ''),
].join(';'));
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_csv', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } });
reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="rechnungsjournal-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`);
return reply.send('' + lines.join('\r\n') + '\r\n');
});
/** Alle passenden Rechnungs-PDFs als ZIP (die eigentlichen Belege, wie sie GoBD neben dem Journal verlangt). */
app.get('/admin/invoices/export.zip', async (req, reply) => {
const a = requirePermission(req, 'invoices.read');
const q = exportQuery.parse(req.query);
const { sql, params } = exportFilter(q);
const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 500`, params);
if (rows.length === 0) throw notFound();
const work = await mkdtemp(join(tmpdir(), 'kc-invoice-export-'));
try {
const names: string[] = [];
const seen = new Set<string>();
for (const v of rows) {
const { inv, seller } = await pdfInput(v);
const buf = await streamToBuffer(renderInvoicePdf(inv, seller));
let base = (v.number ?? v.id).replace(/[^A-Za-z0-9._-]/g, '_');
let name = `${base}.pdf`; let n = 2;
while (seen.has(name)) name = `${base}_${n++}.pdf`; // theoretisch eindeutig durch die Rechnungsnummer, doppelte Absicherung
seen.add(name); names.push(name);
await writeFile(join(work, name), buf);
}
await runBin('zip', ['-q', '-X', '_export.zip', ...names], work);
const zip = await readFile(join(work, '_export.zip'));
await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_zip', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } });
reply.header('content-type', 'application/zip').header('content-disposition', `attachment; filename="rechnungen-${q.from ?? 'alle'}_${q.to ?? 'alle'}.zip"`);
return reply.send(zip);
} finally { await rm(work, { recursive: true, force: true }); }
});
/** DATEV-Buchungsstapel (EXTF, Format 700/21) zum direkten Import beim Steuerberater. Pro Rechnung eine
* Buchungszeile je USt-Satz-Gruppe (Konto = aus der Kundennummer abgeleitetes Debitorenkonto, Gegenkonto =
* konfiguriertes Erlöskonto des jeweiligen Satzes) – das "Automatikkonten"-Verfahren, bei dem der
* Steuerschlüssel durch das Erlöskonto selbst festgelegt ist (kein geratener BU-Schlüssel nötig). */
app.get('/admin/invoices/export.datev', async (req, reply) => {
const a = requirePermission(req, 'invoices.read');
if (!(await hasFeature('datev'))) throw forbidden(featureRequiresLicense('datev'), 'LICENSE_REQUIRED');
const q = exportQuery.parse(req.query);
const d = await datevSettings();
if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE');
const { sql, params } = exportFilter(q);
const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params);
if (rows.length === 0) throw notFound();
const erloeskontoFor = (taxBp: number): number | null => (taxBp === 1900 ? d.erloeskonto19 : taxBp === 700 ? d.erloeskonto7 : taxBp === 0 ? d.erloeskonto0 : null);
const errors: string[] = [];
const bookings: string[] = [];
let minDate: Date | null = null; let maxDate: Date | null = null;
for (const v of rows) {
const issueDate = new Date(v.issue_date);
if (!minDate || issueDate < minDate) minDate = issueDate; if (!maxDate || issueDate > maxDate) maxDate = issueDate;
const digits = String(v.customer_number ?? '').replace(/\D/g, '');
if (!digits) { errors.push(`${v.number}: Kundennummer "${v.customer_number}" enthält keine Ziffern – Debitorenkonto nicht ableitbar`); continue; }
const groups = await query('SELECT tax_bp, SUM(gross_cents) AS gross FROM invoice_items WHERE invoice_id = ? GROUP BY tax_bp', [v.id]);
for (const g of groups) {
const gross = Number(g.gross); if (gross === 0) continue;
const konto = erloeskontoFor(g.tax_bp);
if (konto == null) { errors.push(`${v.number}: kein Erlöskonto für ${(g.tax_bp / 100).toLocaleString('de-DE')} % USt konfiguriert (unter Einstellungen → Firma → DATEV-Export ergänzen)`); continue; }
bookings.push(datevRow({
1: datevNum(Math.abs(gross)), 2: datevQ(gross >= 0 ? 'S' : 'H'), 7: digits, 8: String(konto),
10: datevDm(issueDate), 11: datevQ(v.number), 14: datevQ(`Rechnung ${v.number} ${v.org_name}`.slice(0, 60)),
}));
}
}
if (errors.length) throw badRequest(`DATEV-Export nicht möglich – bitte zuerst beheben:\n${errors.slice(0, 20).join('\n')}${errors.length > 20 ? `\n… und ${errors.length - 20} weitere` : ''}`, 'DATEV_MAPPING_INCOMPLETE');
if (bookings.length === 0) throw notFound();
const from = q.from ? new Date(q.from) : minDate!; const to = q.to ? new Date(q.to) : maxDate!;
const [fyMonth, fyDay] = d.fiscalYearStart.split('-').map(Number);
const wjBeginn = new Date(Date.UTC(from.getUTCFullYear(), fyMonth! - 1, fyDay));
const now = new Date();
const erzeugtAm = `${datevYmd(now)}${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}${String(now.getUTCMilliseconds()).padStart(3, '0')}`;
const s = await settings();
const header = [
datevQ('EXTF'), '700', '21', datevQ('Buchungsstapel'), '13', erzeugtAm, '', datevQ('KC'), datevQ((s.name ?? 'Kundencenter').slice(0, 25)), '',
String(d.beraterNr), String(d.mandantNr), datevYmd(wjBeginn), String(d.sachkontenlaenge), datevYmd(from), datevYmd(to),
datevQ(`Rechnungen ${datevYmd(from)}-${datevYmd(to)}`.slice(0, 30)), d.diktatkuerzel ? datevQ(d.diktatkuerzel) : '', '1', '', '', datevQ('EUR'),
'', '', '', '', d.skr ? datevQ(d.skr) : '', '', '', '', '',
].join(';');
const lines = [header, DATEV_BOOKING_HEADER.join(';'), ...bookings];
await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_datev', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length, bookings: bookings.length } });
reply.header('content-type', 'text/csv; charset=windows-1252').header('content-disposition', `attachment; filename="EXTF_Buchungsstapel_${datevYmd(from)}_${datevYmd(to)}.csv"`);
return reply.send(toWin1252(lines.join('\r\n') + '\r\n'));
});
},
};

View file

@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { checkLicenseNow, getEdition, getEntitlement, setLicenseKey, FEATURE_LABEL, LICENSED_FEATURES, LicenseCheckError } from '../../core/license.js';
import type { KcModule } from '../../core/module.js';
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
* dem "licensing"-Connector (Einstellungen > Verbindungen), der KUNDEN-Lizenzen verwaltet. */
export const licenseModule: KcModule = {
name: 'license',
permissions: {
staff: { admin: ['license.read'], superadmin: ['license.read', 'settings.write'] },
},
register(app: FastifyInstance) {
app.get('/admin/license/status', async (req) => {
requirePermission(req, 'license.read');
const ent = await getEntitlement();
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')");
const staff = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const ed = await getEdition();
const edition = { ...ed, staffCount: Number(staff?.n ?? 0), allFeatures: LICENSED_FEATURES.map((f) => ({ key: f, label: FEATURE_LABEL[f], enabled: ed.features.includes(f) })) };
return { ...ent, edition, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
});
app.put('/admin/license/settings', async (req) => {
const a = requirePermission(req, 'settings.write');
const b = z.object({ licenseKey: z.string().trim().min(8).max(200) }).parse(req.body);
await setLicenseKey(b.licenseKey);
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.settings.update', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { licenseKey: '***' } });
let checked = false; let error: string | undefined;
try { await checkLicenseNow(); checked = true; } catch (e) { error = (e as Error).message; }
return { ok: true, checked, error };
});
app.post('/admin/license/check', async (req) => {
const a = requirePermission(req, 'settings.write');
try {
const r = await checkLicenseNow();
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.check', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { valid: r.valid, plan: r.plan } });
return r;
} catch (e) {
if (e instanceof LicenseCheckError) throw badRequest(e.message, 'LICENSE_CHECK_FAILED');
throw e;
}
});
},
};

View file

@ -0,0 +1,266 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { ConnectorError } from '@kc/connector-sdk';
import { createLicensingAdmin, loadInstance, upsertResource, type LicensingAdmin } from '@kc/connectors';
import { one, query, run } from '../../core/db.js';
import { rl } from '../../core/config.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
/**
* Lizenzverwaltung: Übersicht, Vergabe und Pflege von Kunden-Lizenzen im eigenen Lizenzsystem (licensing.flessinglabs.com).
* Datenbasis der Übersicht ist der Abgleich (resources, type = 'license'); Detail und Änderungen gehen live ans Lizenzsystem.
* Vergabe "mit Vertrag" läuft über den normalen Bestellweg (POST /orders), hier nur die Vergabe ohne Berechnung.
* Nicht zu verwechseln mit dem Modul "license" (eigene Lizenz dieser Installation).
*/
const json = <T>(v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T));
const LIC_SQL = `SELECT r.*, i.connector_key, i.health, i.enabled AS inst_enabled, o.name AS org_name, o.customer_number,
(SELECT c.id FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_id,
(SELECT c.number FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_number
FROM resources r JOIN connector_instances i ON i.id = r.instance_id LEFT JOIN organizations o ON o.id = r.org_id
WHERE r.type = 'license' AND i.connector_key = 'licensing'`;
function listView(r: any) {
const d = json<{ details?: Record<string, any>; limits?: Record<string, any> }>(r.data_json, {});
const x = d.details ?? {};
return {
id: r.id, name: r.name, state: r.state, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
orgId: r.org_id, orgName: r.org_name ?? null, customerNumber: r.customer_number ?? null, contractId: r.contract_id ?? null, contractNumber: r.contract_number ?? null,
program: x.program ?? null, programId: x.programId ?? null, product: x.product ?? null, edition: x.edition ?? null, keyMasked: x.licenseKeyMasked ?? null,
activationsUsed: x.activationsUsed ?? 0, activationLimit: x.activationLimit ?? null, trial: !!x.trial, trialPending: !!x.trialPending, addon: !!x.addon,
parentLicenseId: x.parentLicenseId ?? null, revoked: !!x.revoked, externalRef: r.external_ref,
};
}
const access = (a: AuthContext, r: any) => can(a.principal, 'licenses.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.read', 'licenses.read'));
/** Kunden-Selbstbedienung: Inhaber/Admin der Organisation dürfen eigene Geräte freigeben (Aktivierung zurücksetzen). */
const canResetActivation = (a: AuthContext, r: any) => can(a.principal, 'licenses.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.manage', 'licenses.write'));
async function loadLicense(id: string) { return one(`${LIC_SQL} AND r.id = ?`, [id]); }
async function adminFor(instanceId: string, correlationId: string): Promise<LicensingAdmin> {
const { inst, ctx } = await loadInstance(instanceId, correlationId);
if (!inst.enabled) throw new AppError(409, 'CONNECTOR_DISABLED', 'Die Verbindung zum Lizenzsystem ist deaktiviert.');
return createLicensingAdmin(ctx);
}
/** Fehler des Lizenzsystems verständlich weitergeben (abgelehnte Eingaben mit dessen Begründung). */
function providerError(e: unknown): never {
if (e instanceof ConnectorError) {
if (e.code === 'INVALID_INPUT' || e.code === 'CONFLICT') throw new AppError(e.code === 'CONFLICT' ? 409 : 400, 'LICENSING_REJECTED', `Das Lizenzsystem lehnt das ab: ${e.detail ?? e.userMessage}`);
if (e.code === 'NOT_FOUND') throw new AppError(404, 'LICENSING_NOT_FOUND', 'Die Lizenz wurde im Lizenzsystem nicht gefunden.');
throw new AppError(502, 'CONNECTOR_ERROR', `Lizenzsystem: ${e.userMessage}`);
}
throw e;
}
/** Nach einer Änderung: lokalen Stand sofort aktualisieren (Übersicht) und vollständigen Abgleich anstoßen. */
async function refresh(admin: LicensingAdmin, r: any, raw: Parameters<LicensingAdmin['normalize']>[0] | undefined, correlationId: string) {
if (raw) await upsertResource(r.instance_id, await admin.normalize(raw)).catch(() => undefined);
await enqueue('connector.sync', { instanceId: r.instance_id }, { idempotencyKey: `sync:${r.instance_id}:licensing:${Math.floor(Date.now() / 15000)}`, correlationId });
}
const reasonSchema = z.string().trim().max(255).optional();
export const licensingModule: KcModule = {
name: 'licensing',
permissions: {
staff: { support: ['licenses.read'], accounting: ['licenses.read'], admin: ['licenses.read', 'licenses.write'], superadmin: ['licenses.read', 'licenses.write'] },
org: { owner: ['licenses.read', 'licenses.manage'], admin: ['licenses.read', 'licenses.manage'], member: ['licenses.read'] },
},
register(app: FastifyInstance) {
// ---- Übersicht -------------------------------------------------------------------------------------------
app.get('/licenses', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), state: z.enum(['active', 'suspended', 'expired']).optional(), expiring: z.enum(['1']).optional(), unassigned: z.enum(['1']).optional(), q: z.string().trim().max(100).optional() }).parse(req.query);
const staff = can(a.principal, 'licenses.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && orgs.length === 0) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.state) { where.push('r.state = ?'); params.push(q.state); }
if (q.expiring) where.push("r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)");
if (q.unassigned && staff) where.push('r.org_id IS NULL');
if (q.q) { where.push('(r.name LIKE ? OR o.name LIKE ? OR o.customer_number = ? OR r.external_ref = ?)'); params.push(`%${q.q}%`, `%${q.q}%`, q.q, q.q); }
const rows = await query(`${LIC_SQL}${where.length ? ' AND ' + where.join(' AND ') : ''} ORDER BY (r.valid_until IS NULL), r.valid_until, r.name LIMIT 1000`, params);
return rows.map(listView);
});
app.get('/admin/licenses/summary', async (req) => {
requirePermission(req, 'licenses.read');
const s = await one(`SELECT COUNT(*) AS total, SUM(r.state = 'active') AS active, SUM(r.state = 'suspended') AS suspended, SUM(r.state = 'expired') AS expired,
SUM(r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) AS expiring, SUM(r.org_id IS NULL) AS unassigned
FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.type = 'license' AND i.connector_key = 'licensing' AND r.missing_since IS NULL`);
const n = (v: unknown) => Number(v ?? 0);
return { total: n(s?.total), active: n(s?.active), suspended: n(s?.suspended), expired: n(s?.expired), expiring: n(s?.expiring), unassigned: n(s?.unassigned) };
});
// ---- Detail (live aus dem Lizenzsystem, Rückfall auf den letzten Abgleich) --------------------------------
app.get('/licenses/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadLicense(id);
if (!r || !access(a, r)) throw notFound();
const staff = can(a.principal, 'licenses.read'); const write = can(a.principal, 'licenses.write');
const base = listView(r);
let live: Awaited<ReturnType<LicensingAdmin['get']>> | null = null; let liveError: string | null = null;
try { live = await (await adminFor(r.instance_id, req.correlationId)).get(r.external_ref); }
catch (e) { liveError = e instanceof ConnectorError ? e.userMessage : e instanceof AppError ? e.message : 'Das Lizenzsystem ist nicht erreichbar.'; }
if (live) await upsertResource(r.instance_id, live.resource).catch(() => undefined);
// Add-ons dieser Lizenz und (bei Add-ons) die Basislizenz, soweit im Kundencenter bekannt
const addons = (await query(`${LIC_SQL} AND r.instance_id = ? AND JSON_VALUE(r.data_json, '$.details.parentLicenseId') = ?`, [r.instance_id, r.external_ref]))
.filter((x) => access(a, x)).map(listView);
const parentRef = live?.raw.parent_license_id ?? base.parentLicenseId;
const parent = parentRef ? await one(`${LIC_SQL} AND r.instance_id = ? AND r.external_ref = ?`, [r.instance_id, String(parentRef)]) : null;
const history = staff ? (await query("SELECT action, actor_id, result, ts AS created_at FROM audit_events WHERE resource_type = 'resource' AND resource_id = ? ORDER BY id DESC LIMIT 30", [id])).map((h) => ({ action: h.action, result: h.result, at: h.created_at, actorId: h.actor_id })) : [];
const actorNames = new Map((history.length ? await query(`SELECT id, name FROM users WHERE id IN (${[...new Set(history.map((h) => h.actorId).filter(Boolean))].map(() => '?').join(',') || 'NULL'})`, [...new Set(history.map((h) => h.actorId).filter(Boolean))]) : []).map((u) => [u.id, u.name]));
const caps = json<string[]>((await one('SELECT capabilities_json FROM connector_instances WHERE id = ?', [r.instance_id]))?.capabilities_json, []);
return {
...(live ? listView({ ...r, name: live.resource.name, state: live.resource.state, valid_from: live.resource.validFrom, valid_until: live.resource.validUntil, data_json: { details: live.resource.details } }) : base),
live: !!live, liveError,
activations: live?.activations ?? [], entitlement: live?.entitlement ?? null, limits: live?.limits ?? null,
origin: staff ? (live?.origin ?? null) : null, providerStatus: live?.raw.status ?? null, revokeReason: staff ? (live?.raw.revoke_reason ?? null) : null,
durationType: live?.raw.duration_type ?? null, productId: live?.raw.product_id ?? null, lastCheckAt: live?.raw.last_check_at ?? null,
addons, parent: parent && access(a, parent) ? listView(parent) : null,
history: history.map((h) => ({ ...h, actor: h.actorId ? (actorNames.get(h.actorId) ?? null) : 'System' })),
can: { reveal: caps.includes('secret.reveal') && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'))), // gleiche Regel wie /resources/:id/reveal
resetActivation: !!live && canResetActivation(a, r), manage: write && !!live },
};
});
// ---- Aktivierung (Gerät) freigeben: Personal oder Kunde (Inhaber/Admin) für die eigene Lizenz ------------
app.delete('/licenses/:id/activations/:activationId', { config: rl(10, '10 minutes') }, async (req) => {
const a = requireAuth(req);
const { id, activationId } = z.object({ id: z.string().uuid(), activationId: z.coerce.number().int().positive() }).parse(req.params);
const r = await loadLicense(id);
if (!r || !access(a, r)) throw notFound();
if (!canResetActivation(a, r)) throw forbidden('Geräte dieser Lizenz können nur vom Inhaber oder Support freigegeben werden', 'ACTIVATION_RESET_FORBIDDEN');
const admin = await adminFor(r.instance_id, req.correlationId);
try { await admin.deleteActivation(r.external_ref, activationId); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.activation.reset', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { activationId } });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId);
return { ok: true };
});
// ---- Personal: Limits, Produkt (Upgrade/Testumwandlung), Entitlement, Lebenszyklus --------------------------
app.patch('/admin/licenses/:id', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({
maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).nullable().optional(),
customerLimit: z.number().int().min(0).max(1000000).nullable().optional(), graceDays: z.number().int().min(0).max(365).nullable().optional(),
productId: z.number().int().positive().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), expiresAt: z.iso.datetime().nullable().optional(),
}).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
const body: Record<string, unknown> = {};
if (b.maxActivations !== undefined) body.max_activations = b.maxActivations;
if (b.userLimit !== undefined) body.user_limit = b.userLimit;
if (b.customerLimit !== undefined) body.customer_limit = b.customerLimit;
if (b.graceDays !== undefined) body.grace_days = b.graceDays;
if (b.productId !== undefined) body.product_id = b.productId;
if (b.durationType !== undefined) body.duration_type = b.durationType;
if (b.expiresAt !== undefined) body.expires_at = b.expiresAt;
if (!Object.keys(body).length) throw badRequest('Keine Änderung angegeben');
const admin = await adminFor(r.instance_id, req.correlationId);
let raw; try { raw = await admin.update(r.external_ref, body); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw ?? raw, req.correlationId);
return { ok: true };
});
app.post('/admin/licenses/:id/entitlement', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ fromProduct: z.boolean().default(false), planKey: z.string().trim().max(50).optional(), modules: z.array(z.string().trim().min(1).max(100)).max(200).optional(),
customerLimit: z.number().int().min(0).max(1000000).optional(), clearCustomerLimit: z.boolean().default(false), reason: reasonSchema }).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
const admin = await adminFor(r.instance_id, req.correlationId);
try { await admin.entitlement(r.external_ref, { from_product: b.fromProduct, plan_key: b.planKey, modules: b.modules, customer_limit: b.customerLimit, clear_customer_limit: b.clearCustomerLimit, reason: b.reason ?? `Kundencenter (${a.user.name})` }); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.entitlement', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId);
return { ok: true };
});
app.post('/admin/licenses/:id/lifecycle', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend', 'revoke']), until: z.iso.datetime().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), count: z.number().int().min(1).max(120).optional(), reason: reasonSchema }).parse(req.body);
if (b.action === 'extend' && !b.until && !b.durationType) throw badRequest('Bitte ein Datum oder eine Laufzeit angeben');
if (b.action === 'revoke' && !b.reason) throw badRequest('Bitte einen Grund für den Widerruf angeben');
const r = await loadLicense(id); if (!r) throw notFound();
// Idempotenz pro Bestätigungsdialog (Header), sonst pro Minute: ein Doppelklick verlängert nicht zweimal
const hdr = req.headers['idempotency-key'];
const key = typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : `${id}:${b.action}:${b.until ?? ''}:${b.durationType ?? ''}:${b.count ?? ''}:${Math.floor(Date.now() / 60000)}`;
const body: Record<string, unknown> = { reason: b.reason ?? `Kundencenter (${a.user.name})` };
if (b.action === 'extend') Object.assign(body, b.until ? { until: b.until } : { duration_type: b.durationType, count: b.count ?? 1 });
const admin = await adminFor(r.instance_id, req.correlationId);
let out; try { out = await admin.lifecycle(r.external_ref, b.action, body, `kc:${key}`); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `license.${b.action}`, resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, changed: out?.changed } });
await refresh(admin, r, out?.license, req.correlationId);
return { ok: true, changed: !!out?.changed };
});
app.patch('/admin/licenses/:id/assign', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ orgId: z.string().uuid().nullable() }).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden');
await run('UPDATE resources SET org_id = ? WHERE id = ?', [b.orgId, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id }, after: { orgId: b.orgId } });
return { ok: true };
});
// ---- Vergabe ohne Berechnung (Kulanz, Test, intern). Mit Vertrag: normaler Bestellweg (POST /orders). ---------
app.get('/admin/licenses/catalog', async (req) => {
requirePermission(req, 'licenses.write');
const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1");
if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet (Einstellungen → Verbindungen).');
let catalog; try { catalog = await (await adminFor(inst.id, req.correlationId)).catalog(); } catch (e) { providerError(e); }
// Produkte des Kundencenters, die eine Lizenz bereitstellen (für "mit Vertrag")
const shop = (await query(`SELECT p.id, v.name, v.recurring_cents, v.setup_cents, v.price_basis, v.billing_interval, v.term_months, v.provisioning_json
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.status = 'active' AND p.connector_instance_id = ? ORDER BY v.name`, [inst.id]))
.map((p) => ({ id: p.id, name: p.name, recurringCents: p.recurring_cents, setupCents: p.setup_cents, priceBasis: p.price_basis, interval: p.billing_interval, termMonths: p.term_months, provisioning: json(p.provisioning_json, {}) }));
return { instanceId: inst.id, ...catalog, shopProducts: shop };
});
app.post('/admin/licenses', { config: rl(20, '1 minute') }, async (req) => {
const a = requirePermission(req, 'licenses.write');
const b = z.object({
orgId: z.string().uuid(), kind: z.enum(['license', 'trial', 'addon']), programId: z.number().int().positive(), productId: z.number().int().positive(),
parentId: z.string().uuid().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).default('YEAR'), expiresAt: z.iso.datetime().optional(),
maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).optional(), customerLimit: z.number().int().min(0).max(1000000).optional(),
keyPrefix: z.enum(['PREMIUM', 'TRIAL', 'LIFETIME']).optional(), note: z.string().trim().max(50).optional(),
}).parse(req.body);
const org = await one("SELECT o.id, o.name, o.customer_number, o.status FROM organizations o WHERE o.id = ?", [b.orgId]);
if (!org) throw badRequest('Kunde nicht gefunden');
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann keine Lizenz vergeben werden', 'ORG_INACTIVE');
const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY (m.role = 'owner') DESC, m.created_at LIMIT 1", [b.orgId]);
const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1");
if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet.');
let parentRef: number | undefined;
if (b.kind === 'addon') {
if (!b.parentId) throw badRequest('Für ein Add-on bitte die Basislizenz wählen');
const p = await loadLicense(b.parentId);
if (!p || p.org_id !== b.orgId || p.instance_id !== inst.id) throw badRequest('Die Basislizenz gehört nicht zu diesem Kunden');
parentRef = Number(p.external_ref);
}
const ref = `kc-${randomUUID()}`; // Herkunft: verhindert Doppelanlage bei Wiederholung (source + external_ref eindeutig)
const customer = { source: 'kundencenter', customer_name: org.name, customer_email: owner?.email ?? null, customer_contact: owner?.name ?? null, customer_reference: org.customer_number, order_ref: b.note ? b.note.slice(0, 50) : 'ohne Berechnung', external_ref: ref };
const admin = await adminFor(inst.id, req.correlationId);
let raw;
try {
if (b.kind === 'trial') {
if (!owner?.email) throw badRequest('Für einen Test braucht der Kunde eine E-Mail-Adresse (Ansprechpartner).');
raw = await admin.createTrial({ program_id: b.programId, product_id: b.productId, max_activations: b.maxActivations, key_prefix: b.keyPrefix, ...customer });
} else {
raw = await admin.create({
program_id: b.programId, product_id: b.productId, duration_type: b.durationType, is_active: true, ...(b.expiresAt ? { expires_at: b.expiresAt } : {}),
max_activations: b.maxActivations, user_limit: b.userLimit, customer_limit: b.kind === 'addon' ? undefined : b.customerLimit, key_prefix: b.keyPrefix,
parent_license_id: parentRef, ...customer,
});
}
} catch (e) { if (e instanceof AppError) throw e; providerError(e); }
if (!raw || typeof raw.id !== 'number') throw new AppError(502, 'CONNECTOR_ERROR', 'Unerwartete Antwort des Lizenzsystems');
const resourceId = await upsertResource(inst.id, await admin.normalize(raw));
await run("UPDATE resources SET org_id = ?, customer_actions = COALESCE(customer_actions, '[]') WHERE id = ?", [b.orgId, resourceId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'license.issue', resourceType: 'resource', resourceId, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req),
after: { kind: b.kind, programId: b.programId, productId: b.productId, durationType: b.kind === 'trial' ? 'TRIAL' : b.durationType, expiresAt: b.expiresAt, maxActivations: b.maxActivations, licenseId: raw.id, billing: 'none', note: b.note } });
return { id: resourceId };
});
},
};

View file

@ -6,6 +6,7 @@ import { audit } from '../../core/audit.js';
import { encrypt, decrypt } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { rl } from '../../core/config.js';
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js';
@ -124,6 +125,7 @@ export const mailModule: KcModule = {
secureMode: z.enum(['tls', 'starttls']).default('tls'), username: z.string().trim().max(200).nullable(),
password: z.string().max(500).nullable().optional(), folder: z.string().trim().min(1).max(200).default('INBOX'), enabled: z.boolean().default(false),
}).parse(req.body);
if (b.enabled && !(await hasFeature('imap'))) throw badRequest(featureRequiresLicense('imap'), 'LICENSE_REQUIRED');
const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'folder = ?', 'enabled = ?', 'updated_by = ?'];
const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.folder, b.enabled ? 1 : 0, a.user.id];
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }

View file

@ -5,7 +5,9 @@ import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
import { peekRenewalToken, applyRenewalDecision, RenewalTokenError } from '@kc/platform/contractRenewal';
import { one, query, run, tx } from '../../core/db.js';
import { rl } from '../../core/config.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
@ -51,7 +53,7 @@ export const ordersModule: KcModule = {
permissions: {
staff: {
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write', 'contracts.edit'],
},
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
},
@ -186,7 +188,7 @@ export const ordersModule: KcModule = {
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
return { ...contractView(c), canEdit: can(a.principal, 'contracts.edit') && !['cancelled', 'expired', 'failed'].includes(c.status), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
});
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
app.post('/contracts/:id/cancel', async (req) => {
@ -217,5 +219,54 @@ export const ordersModule: KcModule = {
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
/** Nachträgliche Anpassung (nur Superadmin), z. B. Preis für übernommene Altverträge. Der Preis wird serverseitig neu berechnet. */
app.patch('/admin/contracts/:id', async (req) => {
const a = requirePermission(req, 'contracts.edit');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({
basis: z.enum(['net', 'gross']).optional(), interval: z.enum(['once', 'monthly', 'yearly']).optional(),
recurringCents: z.number().int().min(0).max(100_000_000).optional(), setupCents: z.number().int().min(0).max(100_000_000).optional(),
discountBp: z.number().int().min(0).max(10000).optional(),
termEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), renewal: z.enum(['auto', 'none']).optional(),
renewalTermMonths: z.number().int().min(0).max(120).optional(), noticeDays: z.number().int().min(0).max(365).optional(),
reason: z.string().trim().min(3).max(300),
}).parse(req.body);
return tx(async (c) => {
const k = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [id], c);
if (!k) throw notFound();
if (['cancelled', 'expired', 'failed'].includes(k.status)) throw badRequest('Beendete Verträge können nicht mehr angepasst werden', 'CONTRACT_ENDED');
const snap = typeof k.price_snapshot_json === 'string' ? JSON.parse(k.price_snapshot_json) : k.price_snapshot_json;
let price;
try {
price = calculatePrice({ basis: b.basis ?? snap.basis, interval: b.interval ?? snap.interval, setupCents: b.setupCents ?? snap.unitSetupCents, recurringCents: b.recurringCents ?? snap.unitRecurringCents, taxBp: snap.taxBp, quantity: snap.quantity, discountBp: b.discountBp ?? snap.discountBp, currency: snap.currency });
} catch (e) { if (e instanceof RangeError) throw badRequest(e.message); throw e; }
const renewal = b.renewal ?? k.renewal; const renewalTermMonths = b.renewalTermMonths ?? Number(k.renewal_term_months); const noticeDays = b.noticeDays ?? Number(k.notice_days);
if (renewal === 'auto' && renewalTermMonths < 1) throw badRequest('Bei automatischer Verlängerung muss die Verlängerungsdauer mindestens 1 Monat sein');
const termEnd = b.termEnd === undefined ? k.term_end : b.termEnd === null ? null : new Date(`${b.termEnd}T00:00:00Z`);
const next = { ...snap, ...price, terms: { ...(snap.terms ?? {}), renewal, renewalTermMonths, noticeDays }, adjusted: { at: new Date().toISOString(), by: a.user.id, reason: b.reason } };
// Neues Laufzeitende = neue Erinnerung fällig
const termChanged = b.termEnd !== undefined && String(termEnd ?? '') !== String(k.term_end ?? '');
await run(`UPDATE contracts SET price_snapshot_json = ?, term_end = ?, renewal = ?, renewal_term_months = ?, notice_days = ?${termChanged ? ', renewal_reminder_sent_at = NULL' : ''} WHERE id = ?`,
[JSON.stringify(next), termEnd, renewal, renewalTermMonths, noticeDays, id], c);
await audit({ actorType: 'user', actorId: a.user.id, orgId: k.org_id, action: 'contract.adjust', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req),
before: { recurring: snap.recurring, setup: snap.setup, basis: snap.basis, interval: snap.interval, discountBp: snap.discountBp, termEnd: k.term_end ? new Date(k.term_end).toISOString() : null, renewal: k.renewal, renewalTermMonths: k.renewal_term_months, noticeDays: k.notice_days },
after: { recurring: price.recurring, setup: price.setup, basis: price.basis, interval: price.interval, discountBp: price.discountBp, termEnd: termEnd ? new Date(termEnd).toISOString() : null, renewal, renewalTermMonths, noticeDays, reason: b.reason } }, c);
return { ok: true };
});
});
// ---- Vertrags-Erinnerung per Mail: "Behalten"/"Kündigen" ohne Login, über einen Einmal-Link -------------
app.get('/contracts/renewal-decision', async (req) => {
const { token } = z.object({ token: z.string().min(20).max(100) }).parse(req.query);
const info = await peekRenewalToken(token);
if (!info) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
return info;
});
app.post('/contracts/renewal-decision', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), action: z.enum(['keep', 'cancel']) }).parse(req.body);
try { return await applyRenewalDecision(b.token, b.action); }
catch (e: unknown) { if (e instanceof RenewalTokenError) throw badRequest(e.message, 'INVALID_TOKEN'); throw e; }
});
},
};

View file

@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID, createHash } from 'node:crypto';
import { domainToUnicode } from 'node:url';
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
import { decrypt, encrypt, randomToken } from '../../core/crypto.js';
@ -88,7 +89,10 @@ export const resourcesModule: KcModule = {
const staff = can(a.principal, 'resources.read');
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
// Registrierte Domains aus der Domain-Aufstellung (Laufzeit/Registrierung), nicht die Live-Einträge im Panel; Preise nur für Personal
const domains = (await query('SELECT id, domain, term_months, procurement, sell_gross_cents FROM domain_records WHERE resource_id = ? ORDER BY domain', [id]))
.map((x) => ({ id: x.id, domain: domainToUnicode(x.domain) || x.domain, termMonths: x.term_months, registered: x.procurement !== 'open', ...(staff ? { procurement: x.procurement, sellGrossCents: x.sell_gross_cents } : {}) }));
return { ...view(r, staff), domains, limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
});
app.post('/resources/:id/actions', async (req, reply) => {

View file

@ -3,15 +3,17 @@ import { createReadStream, createWriteStream, existsSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import { join, basename, dirname } from 'node:path';
import mysql from 'mysql2/promise';
import { config } from '../core/config.js';
import { query, one } from '../core/db.js';
import { audit, verifyAuditChain } from '../core/audit.js';
import { enqueue } from '../core/jobs.js';
import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js';
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
import { getBackupPassword } from './settings.js';
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
import { hasFeature } from '../core/license.js';
/** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */
export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string }
@ -72,6 +74,21 @@ async function tableCounts(q: (sql: string) => Promise<any[]>): Promise<Record<s
return out;
}
/** Zählt Dateien/Bytes rekursiv (für Ticket-Anhänge). Fehlt das Verzeichnis, gilt das als leer, nicht als Fehler. */
async function dirStats(dir: string): Promise<{ files: number; bytes: number }> {
if (!existsSync(dir)) return { files: 0, bytes: 0 };
let files = 0; let bytes = 0;
const walk = async (d: string) => {
for (const e of await readdir(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) await walk(p);
else if (e.isFile()) { files += 1; bytes += (await stat(p)).size; }
}
};
await walk(dir);
return { files, bytes };
}
/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */
export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonNullable<Status['lastRun']>> {
const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record<string, string> }[] = []; const built: Remote[] = [];
@ -87,8 +104,12 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonN
for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f));
const counts = await tableCounts((sql) => query(sql));
const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string);
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']);
const attachments = await dirStats(ATTACHMENT_DIR);
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, attachments, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
const tarArgs = ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json'];
// Ticket-Anhänge liegen außerhalb von work (STORE_ROOT); eigener -C-Abschnitt bettet sie unter ihrem Basisnamen mit ein.
if (attachments.files > 0) tarArgs.push('-C', dirname(ATTACHMENT_DIR), basename(ATTACHMENT_DIR));
await run('tar', tarArgs);
const out = join(c.dir, name);
if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password);
else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]);
@ -99,9 +120,11 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonN
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
targets.push({ name: `lokal (${c.dir})`, ok: true });
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
const remoteAllowed = await hasFeature('backup_remote');
if (!remoteAllowed && ((await loadTargets(true).catch(() => [])).length || c.remotes.length)) targets.push({ name: 'Externe Ziele', ok: true, error: 'übersprungen: nur mit Lizenz' });
if (remoteAllowed) try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); }
for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
if (remoteAllowed) for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
const okDests: typeof dests = [];
for (const d of dests) {
try {
@ -172,6 +195,12 @@ export async function runRestoreTest(c: BackupConfig, file?: string): Promise<No
await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]);
const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256;
if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht');
if (manifest.attachments) {
const extracted = await dirStats(join(work, basename(ATTACHMENT_DIR)));
checks.anhaenge = extracted.files === manifest.attachments.files && extracted.bytes === manifest.attachments.bytes;
checks.anhaengeDateien = extracted.files;
if (!checks.anhaenge) throw new Error(`Ticket-Anhänge in der Sicherung unvollständig: ${extracted.files}/${manifest.attachments.files} Dateien`);
}
const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password });
try {
await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);

View file

@ -132,13 +132,22 @@ describe('Rechte', () => {
});
describe('Audit', () => {
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => {
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt eine eingeschleuste Fälschung', async () => {
expect((await verifyAuditChain()).brokenAt).toBeNull();
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
// audit_events ist auf DB-Ebene unveränderlich (Migration 033); eine Manipulation ist daher nur noch als
// eingeschleuste Fälschung denkbar (z. B. Wiedereinspielen einer alten Sicherung neben der echten Kette),
// nicht mehr als nachträgliches UPDATE einer bestehenden Zeile.
await run("INSERT INTO audit_events (actor_type, action, result, prev_hash, hash, hash_version) VALUES ('system','gefaelscht','success', REPEAT('0',64), REPEAT('f',64), 2)");
const fake = await one("SELECT id FROM audit_events WHERE action = 'gefaelscht'");
expect((await verifyAuditChain()).brokenAt).toBe(fake!.id);
});
it('verweigert UPDATE und DELETE auf audit_events auf DB-Ebene (append-only)', async () => {
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1');
await expect(run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id])).rejects.toThrow(/unveraenderlich/);
await expect(run('DELETE FROM audit_events WHERE id = ?', [first!.id])).rejects.toThrow(/unveraenderlich/);
});
});

View file

@ -88,8 +88,9 @@ describe('Domains: Preisliste, Aufschlag, Prüfung', () => {
const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200);
expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400);
const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' });
expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' });
expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedAt: '2020-04-24' })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered' });
expect(new Date((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0].orderedAt).getFullYear()).toBe(2020);
expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403);
expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter
await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false });

View file

@ -203,6 +203,32 @@ describe('Bruttopreise', () => {
});
});
describe('Vertrag nachträglich anpassen (Superadmin)', () => {
it('berechnet den Preis serverseitig neu, ändert Konditionen und protokolliert; nur Superadmin', async () => {
const admin = await staff('adj-adm@shop.test', 'admin'); const sa = await staff('adj-sa@shop.test', 'superadmin');
const P = await customer(admin, 'private', 'Anna Anpass', 'adj-p@shop.test');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'ADJ-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false,
version: { name: 'Altvertrag', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {} } })).json();
await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] });
await drain();
const k = (await call(app, P.client, 'GET', '/contracts')).json()[0];
const body = { recurringCents: 2499, basis: 'gross', reason: 'Unkostenpreis vereinbart', termEnd: '2027-04-24', noticeDays: 14 };
expect((await call(app, admin, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403);
expect((await call(app, P.client, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { ...body, reason: '' })).statusCode).toBe(400);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { renewal: 'auto', renewalTermMonths: 0, reason: 'ungültig' })).statusCode).toBe(400);
expect((await call(app, admin, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(false);
expect((await call(app, sa, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(true);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(200);
const d = (await call(app, P.client, 'GET', `/contracts/${k.id}`)).json();
expect(d.price.recurring).toEqual({ net: 2100, tax: 399, gross: 2499 }); expect(d.price.basis).toBe('gross'); expect(d.price.name).toBe('Altvertrag');
expect(d.noticeDays).toBe(14); expect(new Date(d.termEnd).getFullYear()).toBe(2027); expect(d.canEdit).toBe(false);
const log = await one("SELECT after_json FROM audit_events WHERE action = 'contract.adjust' AND resource_id = ?", [k.id]);
expect(JSON.stringify(log?.after_json)).toContain('Unkostenpreis vereinbart');
});
});
describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => {
resetMock();

View file

@ -1,9 +1,48 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { Alert, Empty, fmt } from '@/components/ui';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, fmt } from '@/components/ui';
interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null }
interface Settings { retentionDays: number; updatedAt: string | null; lastPurge: { at: string; count: number; throughId: number; retentionDays: number } | null }
function Retention() {
const { can } = useSession(); const w = can('settings.write');
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<Settings>('GET', '/admin/audit/settings').then(setS).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget);
try { await api('PUT', '/admin/audit/settings', { retentionDays: Number(f.get('retentionDays')) }); setOk('Gespeichert.'); void load(); }
catch (x) { setErr(errMsg(x)); }
}
async function purgeNow() {
setErr(''); setOk(''); setBusy(true);
try { const r = await api<{ purged: number }>('POST', '/admin/audit/purge'); setOk(r.purged ? `${r.purged} Einträge gelöscht.` : 'Nichts zu löschen – keine Einträge jenseits der Frist.'); void load(); }
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (!s) return null;
return (<div className="card">
<h2>Aufbewahrung &amp; Export</h2>
<p className="muted small">
Die DSGVO selbst schreibt keine feste Zahl vor, nur den Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e) – Einträge dürfen nicht länger als nötig aufbewahrt werden.
Die Voreinstellung von 180 Tagen ist eine verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten, keine gesetzliche Vorgabe. Bitte an die eigene Lösch-/Aufbewahrungsrichtlinie anpassen.
</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
<form onSubmit={save} className="row" style={{ alignItems: 'flex-end' }}>
<Field id="retentionDays" label="Aufbewahrung (Tage)"><input id="retentionDays" name="retentionDays" type="number" min={30} max={3650} defaultValue={s.retentionDays} disabled={!w} /></Field>
{w && <button className="btn primary" type="submit">Speichern</button>}
{w && <button className="btn" type="button" onClick={purgeNow} disabled={busy}>{busy ? '…' : 'Jetzt aufräumen'}</button>}
<a className="btn" href="/api/admin/audit/export.csv">Export (CSV)</a>
</form>
{s.lastPurge
? <p className="muted small">Letzte Aufräumung: {fmt(s.lastPurge.at)}, {s.lastPurge.count} Einträge gelöscht (Frist damals {s.lastPurge.retentionDays} Tage).</p>
: <p className="muted small">Noch keine Aufräumung durchgeführt.</p>}
</div>);
}
export default function Audit() {
const [list, setList] = useState<Ev[] | null>(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null);
const load = useCallback(() => api<Ev[]>('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]);
@ -12,6 +51,7 @@ export default function Audit() {
<div className="row between"><h1>Audit-Protokoll</h1><button className="btn" onClick={() => api<{ checked: number; brokenAt: number | null }>('GET', '/admin/audit/verify').then(setChain).catch((e) => setErr(errMsg(e)))}>Integrität prüfen</button></div>
{err && <Alert kind="err">{err}</Alert>}
{chain && (chain.brokenAt === null ? <Alert kind="ok">Hash-Kette intakt ({chain.checked} Einträge geprüft).</Alert> : <Alert kind="err">Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.</Alert>)}
<Retention />
<div className="card"><div className="field"><label htmlFor="a">Aktion beginnt mit</label><input id="a" value={action} onChange={(e) => setAction(e.target.value)} placeholder="z. B. auth. oder customer." /></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Einträge" /> :
<div className="tablewrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Akteur</th><th>Objekt</th><th>Ergebnis</th></tr></thead><tbody>

View file

@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { SecretField } from '@/components/SecretField';
@ -14,9 +14,11 @@ interface CRow { id: string; number: string; status: string; productName: string
interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] }
interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean }
export default function Kunde() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const { id } = useParams<{ id: string }>(); const { can, reload } = useSession(); const r = useRouter();
const [contracts, setContracts] = useState<CRow[] | null>(null); const [orders, setOrders] = useState<ORow[] | null>(null); const [ress, setRess] = useState<RRow[] | null>(null);
const [o, setO] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState<string | null>(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null);
const [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false);
const [welcomeBusy, setWelcomeBusy] = useState(false);
const load = useCallback(() => api<Org>('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); api<CRow[]>('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api<ORow[]>('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api<RRow[]>('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]);
if (!o) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
@ -37,7 +39,13 @@ export default function Kunde() {
async function setStatus(status: string) {
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
const [welcomeBusy, setWelcomeBusy] = useState(false);
async function impersonate(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setImpersonateBusy(true); const f = new FormData(e.currentTarget);
try {
await api('POST', `/admin/customers/${id}/impersonate`, { reason: String(f.get('reason') ?? '').trim() });
await reload(); r.push('/dashboard');
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setImpersonateBusy(false); }
}
async function sendWelcome() {
setWelcomeBusy(true);
try { const r = await api<{ status: string }>('POST', `/admin/customers/${id}/welcome-mail`); setMsg({ k: r.status === 'sent' ? 'ok' : 'warn', t: r.status === 'sent' ? 'Begrüßungsmail gesendet.' : `Nicht gesendet (Status: ${r.status}).` }); }
@ -45,9 +53,15 @@ export default function Kunde() {
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
<div className="row between"><h1>{o.name}</h1><div className="row">{can('customers.impersonate') && <button className="btn" onClick={() => setImpersonateOpen(!impersonateOpen)} aria-expanded={impersonateOpen}>Als Kunde ansehen</button>}<Status value={o.status} /></div></div>
<p className="muted"><span className="mono">{o.customerNumber}</span> · <span className="badge info">{o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</span></p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{impersonateOpen && <form className="card" onSubmit={impersonate} role="alertdialog" aria-labelledby="imp-h">
<h2 id="imp-h">Als Kunde ansehen</h2>
<p className="small muted">Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.</p>
<Field id="reason" label="Grund" hint="Pflichtfeld, erscheint im Audit-Protokoll"><input id="reason" name="reason" required minLength={3} maxLength={300} autoFocus /></Field>
<div className="row"><button className="btn primary" type="submit" disabled={impersonateBusy}>{impersonateBusy ? '…' : 'Ansicht wechseln'}</button><button className="btn" type="button" onClick={() => setImpersonateOpen(false)}>Abbrechen</button></div>
</form>}
<form className="card" onSubmit={save}><h2>Stammdaten &amp; Rechnungsanschrift</h2>
<div className="cols"><Field id="name" label={o.customerType === 'business' ? 'Firmenname' : 'Name'}><input id="name" name="name" defaultValue={o.name} disabled={!w} required /></Field>
{o.customerType === 'business' && <Field id="company" label="Firma (Rechnung)"><input id="company" name="company" defaultValue={o.billing.company ?? ''} disabled={!w} /></Field>}

View file

@ -5,7 +5,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui';
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] }
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; orderableByCustomer?: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] }
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
export default function Bestellen() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
@ -23,7 +23,7 @@ export default function Bestellen() {
<h1>Neue Bestellung</h1>{err && <Alert kind="err">{err}</Alert>}
{staff && <div className="card"><Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setSel(null); }}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name} ({c.customerType === 'private' ? 'Privat' : 'Geschäft'})</option>)}</select></Field></div>}
{cat === null ? <p className="muted" role="status">Wird geladen …</p> : cat.length === 0 ? <div className="card"><Empty title="Keine Produkte bestellbar">Aktuell sind keine Produkte für die Selbstbestellung freigegeben.</Empty></div> : (<>
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{staff && p.orderableByCustomer === false && <p className="small muted">Nur durch das Personal bestellbar</p>}{p.description && <p className="muted">{p.description}</p>}
<div className="small"><PriceText p={p.price} priv={priv} /></div>
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => { setSel(p); setTerm(''); }}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>

View file

@ -9,6 +9,7 @@ interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; b
export default function Dashboard() {
const { me, can } = useSession();
const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null);
const [licenses, setLicenses] = useState<{ active: number; expiring: number; unassigned: number } | null>(null);
const [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
useEffect(() => {
if (me?.kind === 'customer') {
@ -18,6 +19,7 @@ export default function Dashboard() {
if (!me || me.kind !== 'staff') return;
if (can('customers.read')) api<unknown[]>('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e)));
if (can('jobs.read')) api<Sys>('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e)));
if (can('licenses.read')) api<{ active: number; expiring: number; unassigned: number }>('GET', '/admin/licenses/summary').then(setLicenses).catch(() => undefined);
}, [me, can]);
if (!me) return null;
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
@ -37,6 +39,7 @@ export default function Dashboard() {
</>) : (<>
<div className="grid">
{customers !== null && <div className="card"><div className="stat">{customers}</div><p className="muted">Kunden</p><Link href="/admin/kunden">Kunden verwalten</Link></div>}
{licenses && <div className="card"><div className="stat">{licenses.active}</div><p className="muted">Aktive Lizenzen</p>{licenses.expiring > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.expiring} laufen in 30 Tagen ab</span></p>}{licenses.unassigned > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.unassigned} ohne Kunde</span></p>}<Link href="/lizenzen">Lizenzen verwalten</Link></div>}
{sys && <div className="card"><div className="stat">{sys.jobs.scheduled ?? 0}</div><p className="muted">Wartende Aufträge</p></div>}
{sys?.backup && <div className="card"><h3>Backup</h3><p className="small"><Link href="/einstellungen/backup">Details und Einstellungen</Link></p>
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>

View file

@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
function Guide({ redirectUri }: { redirectUri: string }) {
@ -13,7 +13,7 @@ function Guide({ redirectUri }: { redirectUri: string }) {
<ol className="small" style={{ margin: '16px 0 0', paddingLeft: 20, display: 'grid', gap: 12 }}>
<li><strong>Anwendung anlegen:</strong> Auf <a href="https://discord.com/developers/applications" target="_blank" rel="noreferrer">discord.com/developers/applications</a> auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).</li>
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die <strong>Message Content Intent</strong> aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).</li>
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen (für Tickets als eigene Kanäle zusätzlich „Manage Channels“, „Manage Roles“ und „Attach Files“). Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>Kunden-Anmeldung (OAuth) einrichten:</strong> Im Reiter „OAuth2 → General“ die <strong>Client ID</strong> und (unter „Reset Secret“) das <strong>Client Secret</strong> kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: <code className="mono">{redirectUri}</code></li>
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.</li>
@ -37,7 +37,7 @@ export default function DiscordSettings() {
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? '');
try {
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), ticketCategoryId: v('ticketCategoryId'), ticketLogChannelId: v('ticketLogChannelId'), supportRoleIds: v('supportRoleIds'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
@ -69,7 +69,14 @@ export default function DiscordSettings() {
<Field id="token" label="Bot-Token" hint={s.hasToken ? 'Ein Token ist gespeichert. Leer lassen, um ihn unverändert zu lassen.' : 'Noch kein Token gespeichert.'}><input id="token" name="token" type="password" autoComplete="new-password" disabled={!w} /></Field>
<Field id="guildId" label="Server-ID (Guild-ID)"><input id="guildId" name="guildId" defaultValue={s.guildId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="adminChannelId" label="Kanal-ID für Systemmeldungen"><input id="adminChannelId" name="adminChannelId" defaultValue={s.adminChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads"><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads" hint="Nur ohne Ticket-Kategorie: private Threads, nur für Kunden mit verknüpftem Discord."><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
</div>
<h3>Tickets als eigene Kanäle</h3>
<p className="muted small">Mit einer Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal darin. Sehen können ihn nur die Support-Rollen und die Mitglieder des Kunden, die ihr Discord verknüpft haben. Beim Schließen wird der Kanal entfernt, der Verlauf bleibt im Kundencenter. Meldungen zu neuen Tickets gehen in den Support-Kanal (wird bei leerem Feld automatisch als <code className="mono">#ticket-log</code> angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.</p>
<div className="cols">
<Field id="ticketCategoryId" label="Kategorie-ID für Tickets"><input id="ticketCategoryId" name="ticketCategoryId" defaultValue={s.ticketCategoryId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="supportRoleIds" label="Rollen-IDs Support (kommagetrennt)"><input id="supportRoleIds" name="supportRoleIds" defaultValue={s.supportRoleIds ?? ''} placeholder="123456789012345678, 234567890123456789" disabled={!w} /></Field>
<Field id="ticketLogChannelId" label="Kanal-ID für Ticket-Meldungen (Support)"><input id="ticketLogChannelId" name="ticketLogChannelId" defaultValue={s.ticketLogChannelId ?? ''} placeholder="leer = automatisch anlegen" disabled={!w} /></Field>
</div>
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Bot aktiv</label>
<p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>

View file

@ -4,7 +4,8 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field } from '@/components/ui';
interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean }
interface Datev { beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string; erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null; complete: boolean }
interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean; datev: Datev }
export default function Firma() {
const { can } = useSession(); const w = can('settings.write');
@ -15,12 +16,17 @@ export default function Firma() {
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined);
try {
const n = (k: string) => { const x = v(k); return x === undefined ? null : Number(x); };
await api('PUT', '/admin/company-settings', {
name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE',
taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'),
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
footerText: v('footerText') ?? null,
datevBeraterNr: n('datevBeraterNr'), datevMandantNr: n('datevMandantNr'), datevSkr: (v('datevSkr') as '03' | '04' | undefined) ?? null,
datevSachkontenlaenge: Number(f.get('datevSachkontenlaenge') ?? 4), datevFiscalYearStart: v('datevFiscalYearStart') ?? '01-01',
datevErloeskonto19: n('datevErloeskonto19'), datevErloeskonto7: n('datevErloeskonto7'), datevErloeskonto0: n('datevErloeskonto0'),
datevDiktatkuerzel: v('datevDiktatkuerzel') ?? null,
});
setOk('Gespeichert.'); void load();
} catch (x) { setErr(errMsg(x)); }
@ -54,6 +60,27 @@ export default function Firma() {
<Field id="paymentMethods" label="Zahlungsarten" hint="kommagetrennt"><input id="paymentMethods" name="paymentMethods" defaultValue={s.paymentMethods.join(', ')} disabled={!w} /></Field>
</div>
<Field id="footerText" label="Fußzeile auf der Rechnung (optional)" hint="z. B. Geschäftsführer, Registergericht"><input id="footerText" name="footerText" defaultValue={s.footerText ?? ''} disabled={!w} /></Field>
<h3>DATEV-Export</h3>
<p className="muted small">Für den Buchungsstapel-Export unter Rechnungen → Export. Werte vom Steuerberater erfragen; ohne Beraternummer und Mandantennummer ist der DATEV-Export nicht möglich (das generische CSV/ZIP funktioniert unabhängig davon immer).</p>
{!s.datev.complete && <Alert kind="warn">DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer fehlen noch.</Alert>}
<div className="cols">
<Field id="datevBeraterNr" label="Beraternummer" hint="vom Steuerberater, ≥ 1001"><input id="datevBeraterNr" name="datevBeraterNr" type="number" min={1001} defaultValue={s.datev.beraterNr ?? ''} disabled={!w} /></Field>
<Field id="datevMandantNr" label="Mandantennummer" hint="vom Steuerberater"><input id="datevMandantNr" name="datevMandantNr" type="number" min={1} defaultValue={s.datev.mandantNr ?? ''} disabled={!w} /></Field>
<Field id="datevSkr" label="Kontenrahmen (SKR)">
<select id="datevSkr" name="datevSkr" defaultValue={s.datev.skr ?? ''} disabled={!w}>
<option value="">– bitte wählen –</option><option value="04">SKR04</option><option value="03">SKR03</option>
</select>
</Field>
<Field id="datevSachkontenlaenge" label="Sachkontenlänge" hint="4–8 Stellen, meist 4"><input id="datevSachkontenlaenge" name="datevSachkontenlaenge" type="number" min={4} max={8} defaultValue={s.datev.sachkontenlaenge} disabled={!w} /></Field>
<Field id="datevFiscalYearStart" label="Wirtschaftsjahresbeginn" hint="MM-TT, meist 01-01"><input id="datevFiscalYearStart" name="datevFiscalYearStart" pattern="\d{2}-\d{2}" defaultValue={s.datev.fiscalYearStart} disabled={!w} /></Field>
<Field id="datevDiktatkuerzel" label="Diktatkürzel (optional)" hint="2 Zeichen"><input id="datevDiktatkuerzel" name="datevDiktatkuerzel" maxLength={2} defaultValue={s.datev.diktatkuerzel ?? ''} disabled={!w} /></Field>
</div>
<p className="muted small" style={{ marginTop: 4 }}>Erlöskonten je USt-Satz (Automatikkonten-Verfahren: das Erlöskonto selbst legt den Steuersatz fest, z. B. SKR04 4400/4300 oder SKR03 8400/8300 – beim Steuerberater erfragen).</p>
<div className="cols">
<Field id="datevErloeskonto19" label="Erlöskonto 19 % USt"><input id="datevErloeskonto19" name="datevErloeskonto19" type="number" defaultValue={s.datev.erloeskonto19 ?? ''} disabled={!w} /></Field>
<Field id="datevErloeskonto7" label="Erlöskonto 7 % USt"><input id="datevErloeskonto7" name="datevErloeskonto7" type="number" defaultValue={s.datev.erloeskonto7 ?? ''} disabled={!w} /></Field>
<Field id="datevErloeskonto0" label="Erlöskonto 0 % / steuerfrei"><input id="datevErloeskonto0" name="datevErloeskonto0" type="number" defaultValue={s.datev.erloeskonto0 ?? ''} disabled={!w} /></Field>
</div>
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
</form>
</>);

View file

@ -14,6 +14,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
{ href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') },
{ href: '/einstellungen/lizenz', label: 'Lizenz', show: can('license.read') },
].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<>

View file

@ -0,0 +1,77 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Status {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
customerCount: number;
edition: { mode: 'licensed' | 'trial'; reason: string | null; customerLimit: number | null; staffLimit: number | null; staffCount: number; allFeatures: { key: string; label: string; enabled: boolean }[] };
}
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
export default function LizenzPage() {
const { can } = useSession(); const w = can('settings.write');
const [s, setS] = useState<Status | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<Status>('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
async function saveKey(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(''); setBusy(true);
const f = new FormData(e.currentTarget);
try {
const r = await api<{ ok: boolean; checked: boolean; error?: string }>('PUT', '/admin/license/settings', { licenseKey: String(f.get('licenseKey') ?? '').trim() });
setOk(r.checked ? 'Lizenzschlüssel gespeichert und erfolgreich geprüft.' : `Lizenzschlüssel gespeichert, Prüfung fehlgeschlagen: ${r.error}`);
e.currentTarget.reset(); void load();
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
async function checkNow() {
setErr(''); setOk(''); setBusy(true);
try { await api('POST', '/admin/license/check'); setOk('Lizenz geprüft.'); void load(); }
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
return (<>
<h2>Lizenz</h2>
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{s.edition.mode === 'trial' && <Alert kind="warn"><strong>Testmodus</strong> ({s.edition.reason}): höchstens {s.edition.staffLimit} Personal-Konto und {s.edition.customerLimit} Kunden; {s.edition.allFeatures.filter((f) => !f.enabled).map((f) => f.label).join(', ')} nur mit Lizenz. Bestehende Daten bleiben vollständig nutzbar.</Alert>}
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && <Alert kind="err">Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && <Alert kind="warn">Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.</Alert>}
{s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && <Alert kind="warn">Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.</Alert>}
<div className="card">
<h3>Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Personal-Konten</dt><dd>{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}</dd></div>
<div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}</dd></div>
{s.edition.allFeatures.map((f) => <div key={f.key}><dt className="small muted">{f.label}</dt><dd>{f.enabled ? <span className="badge ok">enthalten</span> : <span className="badge lock">nur mit Lizenz</span>}</dd></div>)}
</dl>
</div>
<div className="card">
<h3>Status</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Plan</dt><dd>{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}</dd></div>
<div><dt className="small muted">Module</dt><dd>{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}</dd></div>
<div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.customerLimit}`}</dd></div>
<div><dt className="small muted">Nutzer-/Slotlimit</dt><dd>{s.userLimit === null ? 'unbegrenzt' : s.userLimit}</dd></div>
<div><dt className="small muted">Gültig bis</dt><dd>{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}</dd></div>
<div><dt className="small muted">Zuletzt geprüft</dt><dd>{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})</dd></div>
</dl>
{w && <button className="btn" disabled={busy || !s.hasKey} onClick={checkNow}>Jetzt prüfen</button>}
</div>
{w && <form className="card" onSubmit={saveKey}>
<h3>Lizenzschlüssel</h3>
<Field id="licenseKey" label="Lizenzschlüssel" hint="vom Lizenzsystem (licensing.flessinglabs.com), z. B. nach einem Plan-Wechsel"><input id="licenseKey" name="licenseKey" placeholder={s.hasKey ? '•••••••• (hinterlegt, zum Ändern neu eingeben)' : ''} /></Field>
<button className="btn primary" type="submit" disabled={busy}>Speichern &amp; prüfen</button>
</form>}
<p className="muted small">Installations-ID: <span className="mono">{s.instanceId}</span> (bleibt stabil, zählt sonst als neues Gerät).</p>
</>);
}

View file

@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
export default function AppLayout({ children }: { children: ReactNode }) {
const { me, loading, can, reload } = useSession(); const r = useRouter(); const path = usePathname(); const [open, setOpen] = useState(false);
const [trial, setTrial] = useState<string | null>(null);
useEffect(() => { if (me?.kind === 'staff' && can('license.read')) api<{ edition: { mode: string; staffLimit: number | null; customerLimit: number | null } }>('GET', '/admin/license/status').then((s) => setTrial(s.edition.mode === 'trial' ? `Testmodus: höchstens ${s.edition.staffLimit} Personal-Konto und ${s.edition.customerLimit} Kunden, ohne Discord-Bot, E-Mail-Posteingang, DATEV-Export und externe Backups.` : null)).catch(() => undefined); }, [me, can]);
const enrollNeeded = !!me?.mfaEnrollRequired;
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, r]);
@ -20,7 +22,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<details className="group" open={inGroup(hrefs)}><summary>{title}</summary><div className="items">{items}</div></details>
);
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); }
const produkte = [(me.kind === 'customer' || can('licenses.read')) && ['/lizenzen', me.kind === 'customer' ? 'Meine Lizenzen' : 'Lizenzen'], (me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
const nav = (
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
@ -43,9 +46,16 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</nav>
);
const bottomLink = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
return (<div className="shell">
return (<>
{me.impersonating && (
<div role="status" style={{ position: 'sticky', top: 0, zIndex: 100, width: '100%', background: 'var(--warn)', color: '#1a1200', padding: '8px 16px', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}>
<span>👁 Ansicht als Kunde: <strong>{me.impersonating.orgName}</strong> ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason}</span>
<button className="btn small" onClick={() => void stopImpersonation()}>Zurück zur Verwaltung</button>
</div>
)}
<div className="shell">
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
{nav}<main id="main" className="main">{children}</main>
{nav}<main id="main" className="main">{trial && path !== '/einstellungen/lizenz' && <div className="alert warn" role="status" style={{ marginBottom: 16 }}>{trial} <Link href="/einstellungen/lizenz">Lizenz hinterlegen</Link></div>}{children}</main>
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
{bottomLink('/dashboard', 'Übersicht')}
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}
@ -53,5 +63,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{(me.kind === 'customer' || can('invoices.read')) && bottomLink('/rechnungen', 'Rechnungen')}
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>Mehr</button>
</nav>}
</div>);
</div>
</>);
}

View file

@ -0,0 +1,180 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, ResState, fmt } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { LicKind, type Lic } from '@/components/Licenses';
interface Activation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string }
interface Detail extends Lic {
live: boolean; liveError: string | null; activations: Activation[];
entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number } | null;
limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null } | null;
origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null } | null;
providerStatus: string | null; revokeReason: string | null; durationType: string | null; productId: number | null; programId: number | null; lastCheckAt: string | null;
addons: Lic[]; parent: Lic | null; history: { action: string; result: string; at: string; actor: string | null }[];
can: { reveal: boolean; resetActivation: boolean; manage: boolean };
}
interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: string; durationType: string; active: boolean }
const DURATION: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'unbefristet', TRIAL: 'Test' };
const HISTORY: Record<string, string> = {
'license.issue': 'Lizenz vergeben', 'license.update': 'Lizenz geändert', 'license.entitlement': 'Funktionsumfang geändert', 'license.suspend': 'Gesperrt', 'license.unsuspend': 'Entsperrt',
'license.extend': 'Verlängert', 'license.revoke': 'Widerrufen', 'license.activation.reset': 'Gerät freigegeben', 'resource.reveal': 'Schlüssel angezeigt', 'resource.update': 'Zuordnung geändert',
'resource.suspend': 'Gesperrt (Vertrag)', 'resource.unsuspend': 'Entsperrt (Vertrag)', 'resource.extend': 'Verlängert (Vertrag)',
};
const Dd = ({ label, children }: { label: string; children: ReactNode }) => <div><dt className="muted small">{label}</dt><dd style={{ margin: 0 }}>{children}</dd></div>;
const toLocalInput = (iso: string | null) => (iso ? new Date(new Date(iso).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 10) : '');
export default function Lizenz() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('licenses.read'); const w = can('licenses.write');
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]); const [assignTo, setAssignTo] = useState('');
const [d, setD] = useState<Detail | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [busy, setBusy] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; text: string; run: () => Promise<unknown> }>(null);
const [products, setProducts] = useState<CatalogProduct[] | null>(null);
const load = useCallback(() => api<Detail>('GET', `/licenses/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (d?.can.manage && products === null) api<{ products: CatalogProduct[] }>('GET', '/admin/licenses/catalog').then((c) => setProducts(c.products)).catch(() => setProducts([])); }, [d?.can.manage, products]);
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function act(fn: () => Promise<unknown>, ok: string) {
setBusy(true); setMsg(null);
try { await fn(); setMsg({ k: 'ok', t: ok }); setConfirm(null); await load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setConfirm(null); } finally { setBusy(false); }
}
const ask = (title: string, text: string, run: () => Promise<unknown>) => setConfirm({ title, text, run });
const lifecycle = (body: Record<string, unknown>) => api('POST', `/admin/licenses/${id}/lifecycle`, body);
const sameKind = (products ?? []).filter((p) => p.programId === d.programId && (p.type === 'ADDON') === d.addon);
const used = d.activations.length; const limit = d.limits?.activationLimit ?? d.activationLimit;
function saveLimits(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const n = (k: string) => { const v = String(f.get(k) ?? '').trim(); return v === '' ? null : Number(v); };
const body: Record<string, unknown> = { maxActivations: n('maxActivations') ?? undefined, userLimit: n('userLimit'), graceDays: n('graceDays') };
if (!d!.addon) body.customerLimit = n('customerLimit');
void act(() => api('PATCH', `/admin/licenses/${id}`, body), 'Limits gespeichert.');
}
function saveEntitlement(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget);
const modules = String(f.get('modules') ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
void act(() => api('POST', `/admin/licenses/${id}/entitlement`, { planKey: String(f.get('plan') ?? '').trim() || undefined, modules, reason: String(f.get('reason') ?? '').trim() || undefined }), 'Funktionsumfang gespeichert (neue Entitlement-Version).');
}
function changeProduct(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const productId = Number(f.get('productId')); const durationType = String(f.get('durationType') ?? '');
const p = sameKind.find((x) => x.id === productId);
ask(d!.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln', `Die Lizenz wird auf „${p?.groupName} – ${p?.name}“ umgestellt${durationType ? ` (Laufzeit: ${DURATION[durationType]}, Ablauf wird neu berechnet)` : ''}. Plan, Module und Kundenlimit werden vom neuen Produkt übernommen.`,
() => api('PATCH', `/admin/licenses/${id}`, { productId, ...(durationType ? { durationType } : {}) }));
}
function extend(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const until = String(f.get('until') ?? ''); const preset = String(f.get('preset') ?? ''); const reason = String(f.get('reason') ?? '').trim() || undefined;
if (until) { const iso = new Date(`${until}T23:59:59`).toISOString(); ask('Verlängern', `Die Lizenz gilt dann bis ${fmt(iso)}.`, () => lifecycle({ action: 'extend', until: iso, reason })); return; }
if (!preset) { setMsg({ k: 'warn', t: 'Bitte ein Datum oder eine Laufzeit wählen.' }); return; }
const [count, durationType] = preset.split(':');
ask('Verlängern', `Die Lizenz wird um ${count} ${DURATION[durationType!]}${Number(count) > 1 ? 'e' : ''} verlängert (ab dem späteren von heute und dem aktuellen Ablauf).`, () => lifecycle({ action: 'extend', durationType, count: Number(count), reason }));
}
return (<>
<p><Link href="/lizenzen">← Alle Lizenzen</Link></p>
<div className="row between"><h1>{d.program ?? d.name}{d.product ? ` · ${d.product}` : ''}</h1><div className="row"><ResState value={d.state} /><LicKind l={d} /></div></div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{!d.live && <Alert kind="warn"><strong>Das Lizenzsystem ist gerade nicht erreichbar</strong> ({d.liveError}). Angezeigt wird der Stand vom {fmt(d.syncedAt)}, Änderungen sind währenddessen nicht möglich.</Alert>}
{d.trial && d.trialPending && <Alert kind="info">Testlizenz: Die Testzeit beginnt erst mit der ersten Aktivierung.</Alert>}
{d.revoked && <Alert kind="err">Diese Lizenz wurde widerrufen{d.revokeReason ? `: ${d.revokeReason}` : ''}. Ein Widerruf ist endgültig.</Alert>}
{d.can.reveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}
{w && <div className="card"><h2>{d.orgId ? 'Kunde ändern' : 'Kunde zuweisen'}</h2>
<div className="row" style={{ gap: 8 }}>
<select aria-label="Kunde wählen" value={assignTo} onChange={(e) => setAssignTo(e.target.value)} style={{ minWidth: 260 }}><option value="">Kunde wählen …</option>{custs.filter((c) => c.id !== d.orgId).map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select>
<button className="btn primary" disabled={!assignTo || busy} onClick={() => { const c = custs.find((x) => x.id === assignTo); ask(d.orgId ? 'Kunde ändern' : 'Kunde zuweisen', `Die Lizenz wird ${c?.customerNumber} · ${c?.name} zugeordnet. Der Kunde sieht sie danach unter „Meine Lizenzen“ samt Schlüssel.`, async () => { await api('PATCH', `/admin/licenses/${id}/assign`, { orgId: assignTo }); setAssignTo(''); }); }}>Zuweisen</button>
{d.orgId && <button className="btn" disabled={busy} onClick={() => ask('Zuordnung lösen', 'Die Lizenz ist danach keinem Kunden mehr zugeordnet und für den bisherigen Kunden nicht mehr sichtbar.', () => api('PATCH', `/admin/licenses/${id}/assign`, { orgId: null }))}>Zuordnung lösen</button>}
</div>
<p className="small muted" style={{ marginTop: 8 }}>Ändert nur die Zuordnung im Kundencenter, nicht die Lizenz im Lizenzsystem.</p></div>}
<div className="card"><h2>Details</h2>
<dl className="cols" style={{ margin: 0 }}>
{staff && <Dd label="Kunde">{d.orgId ? <Link href={`/admin/kunden/${d.orgId}`}>{d.customerNumber} · {d.orgName}</Link> : <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span>}</Dd>}
<Dd label="Programm">{d.program ?? '–'}</Dd>
<Dd label="Produkt">{d.product ?? d.edition ?? '–'}</Dd>
<Dd label="Laufzeit">{d.durationType ? DURATION[d.durationType] ?? d.durationType : '–'}</Dd>
<Dd label="Gültig ab">{d.validFrom ? fmt(d.validFrom) : '–'}</Dd>
<Dd label="Gültig bis">{d.validUntil ? fmt(d.validUntil) : 'unbefristet'}</Dd>
<Dd label="Letzte Prüfung durch das Programm">{d.lastCheckAt ? fmt(d.lastCheckAt) : 'noch nie'}</Dd>
<Dd label="Abrechnung">{d.contractId ? <Link href={`/vertraege/${d.contractId}`}>Vertrag {d.contractNumber}</Link> : 'ohne Berechnung'}</Dd>
{d.parent && <Dd label="Basislizenz"><Link href={`/lizenzen/${d.parent.id}`}>{d.parent.program}{d.parent.product ? ` · ${d.parent.product}` : ''}</Link></Dd>}
{d.entitlement && <Dd label="Plan">{d.entitlement.plan ?? '–'}</Dd>}
{d.entitlement && !d.addon && <Dd label="Kundenlimit">{d.entitlement.customerLimit ?? 'unbegrenzt'}</Dd>}
{staff && d.origin && <Dd label="Herkunft">{d.origin.source ?? '–'}{d.origin.orderRef ? ` · ${d.origin.orderRef}` : ''}</Dd>}
</dl>
{d.entitlement && d.entitlement.modules.length > 0 && <p className="small" style={{ marginTop: 16 }}><span className="muted">Freigeschaltete Module:</span> {d.entitlement.modules.join(', ')}</p>}
</div>
<div className="card"><h2>Geräte <span className="muted small">({used} von {limit ?? '∞'})</span></h2>
{d.activations.length === 0 ? <p className="muted">{d.live ? 'Die Lizenz ist noch auf keinem Gerät aktiviert.' : 'Keine Angaben verfügbar.'}</p> : <div className="tablewrap"><table><thead><tr><th>Gerät</th><th>Umgebung</th><th>Version</th><th>Aktiviert</th><th>Zuletzt gesehen</th>{staff && <th>IP</th>}<th></th></tr></thead><tbody>
{d.activations.map((a) => <tr key={a.id}>
<td className="mono small">{a.instanceId ?? a.hardwareIdMasked ?? `#${a.id}`}</td><td>{a.environment ?? '–'}</td><td>{a.productVersion ?? '–'}</td><td>{fmt(a.activatedAt)}</td><td>{fmt(a.lastSeenAt)}</td>{staff && <td className="mono small">{a.lastSeenIp ?? '–'}</td>}
<td>{d.can.resetActivation && <button className="btn small" disabled={busy} onClick={() => ask('Gerät freigeben', 'Das Gerät wird von der Lizenz gelöst. Das Programm auf diesem Gerät meldet sich danach als nicht lizenziert; der Platz kann auf einem anderen Gerät genutzt werden.', () => api('DELETE', `/licenses/${id}/activations/${a.id}`))}>Freigeben</button>}</td>
</tr>)}</tbody></table></div>}
{!staff && d.can.resetActivation && <p className="small muted" style={{ marginTop: 12 }}>Neues Gerät? Altes Gerät hier freigeben, dann das Programm auf dem neuen Gerät mit demselben Schlüssel aktivieren.</p>}
</div>
{d.can.manage && <>
<div className="card"><h2>Lebenszyklus</h2>
<div className="row" style={{ marginBottom: 16 }}>
{d.state !== 'suspended' && !d.revoked && <button className="btn" disabled={busy} onClick={() => ask('Sperren', 'Das Programm meldet sich danach als nicht lizenziert, bis die Lizenz wieder entsperrt wird.', () => lifecycle({ action: 'suspend' }))}>Sperren</button>}
{d.state === 'suspended' && !d.revoked && <button className="btn" disabled={busy} onClick={() => ask('Entsperren', 'Die Lizenz ist danach wieder nutzbar.', () => lifecycle({ action: 'unsuspend' }))}>Entsperren</button>}
{!d.revoked && <button className="btn" disabled={busy} onClick={() => { const reason = window.prompt('Grund für den Widerruf (Pflicht, wird protokolliert):')?.trim(); if (reason) ask('Endgültig widerrufen', `Der Widerruf ist endgültig und kann nicht rückgängig gemacht werden. Grund: ${reason}`, () => lifecycle({ action: 'revoke', reason })); }}>Widerrufen …</button>}
</div>
{d.durationType !== 'UNLIMITED' && !d.revoked && <form onSubmit={extend}>
<div className="cols">
<Field id="preset" label="Verlängern um"><select id="preset" name="preset" defaultValue=""><option value="">–</option>{!d.trial && <><option value="1:MONTH">1 Monat</option><option value="3:MONTH">3 Monate</option><option value="1:YEAR">1 Jahr</option></>}<option value="1:UNLIMITED" disabled={d.trial}>unbefristet</option></select></Field>
<Field id="until" label="oder gültig bis (Datum)" hint={d.trial ? 'Tests lassen sich nur mit festem Datum verlängern (Kulanz).' : undefined}><input id="until" name="until" type="date" min={toLocalInput(new Date().toISOString())} /></Field>
<Field id="reason" label="Grund (optional, wird protokolliert)"><input id="reason" name="reason" maxLength={255} /></Field>
</div>
<button className="btn primary" type="submit" disabled={busy}>Verlängern</button>
</form>}
</div>
{sameKind.length > 0 && <div className="card"><h2>{d.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln (Upgrade/Downgrade)'}</h2>
{d.trial && /^(TRIAL|PREMIUM|LIFETIME)-/i.test(d.keyMasked ?? '') && <Alert kind="warn">Der Schlüssel trägt ein Editions-Präfix. Programme wie das Familytool erkennen die Edition am Schlüssel – dort bitte stattdessen eine neue Lizenz vergeben.</Alert>}
<form onSubmit={changeProduct}><div className="cols">
<Field id="productId" label="Neues Produkt"><select id="productId" name="productId" required defaultValue={d.productId ?? ''}><option value="" disabled>Produkt wählen …</option>{sameKind.map((p) => <option key={p.id} value={p.id} disabled={p.id === d.productId}>{p.groupName} – {p.name}{p.active ? '' : ' (inaktiv)'}</option>)}</select></Field>
<Field id="durationType" label={d.trial ? 'Laufzeit der Vollversion' : 'Laufzeit (optional neu setzen)'}><select id="durationType" name="durationType" required={d.trial} defaultValue=""><option value="">{d.trial ? 'bitte wählen …' : 'unverändert'}</option>{['MONTH', 'YEAR', 'UNLIMITED'].map((x) => <option key={x} value={x}>{DURATION[x]}</option>)}</select></Field>
</div><button className="btn" type="submit" disabled={busy}>Umstellen …</button></form>
</div>}
<div className="cols">
<div className="card"><h2>Limits</h2>
<form onSubmit={saveLimits}>
<Field id="maxActivations" label="Geräte gleichzeitig"><input id="maxActivations" name="maxActivations" type="number" min={1} max={1000} defaultValue={d.limits?.maxActivations ?? d.limits?.activationLimit ?? 1} /></Field>
<Field id="userLimit" label="Benutzer/Slots (leer = unbegrenzt)"><input id="userLimit" name="userLimit" type="number" min={0} defaultValue={d.limits?.userLimit ?? ''} /></Field>
{!d.addon && <Field id="customerLimit" label="Kundenlimit (leer = unbegrenzt)"><input id="customerLimit" name="customerLimit" type="number" min={0} defaultValue={d.entitlement?.customerLimit ?? ''} /></Field>}
<Field id="graceDays" label={`Offline-Gnadenfrist in Tagen (leer = vom Produkt${d.limits?.effectiveGraceDays != null ? `, derzeit ${d.limits.effectiveGraceDays}` : ''})`}><input id="graceDays" name="graceDays" type="number" min={0} max={365} defaultValue={d.limits?.graceDays ?? ''} /></Field>
<button className="btn" type="submit" disabled={busy}>Limits speichern</button>
</form></div>
<div className="card"><h2>Funktionsumfang <span className="muted small">(Version {d.entitlement?.version ?? 0})</span></h2>
<p className="small muted">Plan und Module sind bei der Vergabe an der Lizenz fixiert. Änderungen am Produkt wirken erst nach „Vom Produkt übernehmen“.</p>
<div className="row" style={{ marginBottom: 12 }}><button className="btn" disabled={busy} onClick={() => ask('Vom Produkt übernehmen', 'Plan, Module und Kundenlimit werden mit den aktuellen Werten des Produkts überschrieben.', () => api('POST', `/admin/licenses/${id}/entitlement`, { fromProduct: true }))}>Vom Produkt übernehmen</button></div>
<form onSubmit={saveEntitlement}>
<Field id="plan" label="Plan"><input id="plan" name="plan" maxLength={50} defaultValue={d.entitlement?.plan ?? ''} /></Field>
<Field id="modules" label="Module (eins pro Zeile oder kommagetrennt)"><textarea id="modules" name="modules" rows={4} defaultValue={(d.entitlement?.modules ?? []).join('\n')} /></Field>
<Field id="ereason" label="Grund (optional)"><input id="ereason" name="reason" maxLength={255} /></Field>
<button className="btn" type="submit" disabled={busy}>Funktionsumfang speichern</button>
</form></div>
</div>
</>}
{(!d.addon && (d.addons.length > 0 || d.can.manage)) && <div className="card"><div className="row between"><h2>Add-ons</h2>{d.can.manage && d.orgId && <Link className="btn small" href={`/lizenzen/neu?org=${d.orgId}&parent=${d.id}`}>Add-on vergeben</Link>}</div>
{d.addons.length === 0 ? <p className="muted">Keine Add-ons.</p> : <ul>{d.addons.map((x) => <li key={x.id}><Link href={`/lizenzen/${x.id}`}>{x.product ?? x.name}</Link> <ResState value={x.state} /> <span className="small muted">bis {x.validUntil ? fmt(x.validUntil) : 'unbefristet'}</span></li>)}</ul>}
</div>}
{staff && d.history.length > 0 && <div className="card"><h2>Verlauf</h2><div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Vorgang</th><th>Durch</th><th>Ergebnis</th></tr></thead><tbody>
{d.history.map((h, i) => <tr key={i}><td>{fmt(h.at)}</td><td>{HISTORY[h.action] ?? h.action}</td><td>{h.actor ?? '–'}</td><td>{h.result === 'success' ? 'ok' : h.result}</td></tr>)}</tbody></table></div></div>}
{confirm && <div role="dialog" aria-modal="true" aria-labelledby="cf-t" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', display: 'grid', placeItems: 'center', padding: 16, zIndex: 50 }}>
<div className="card" style={{ maxWidth: 480, width: '100%' }}><h2 id="cf-t">{confirm.title}</h2><p>{confirm.text}</p>
<div className="row"><button className="btn primary" disabled={busy} onClick={() => void act(confirm.run, `${confirm.title}: erledigt.`)}>{busy ? 'Bitte warten …' : 'Bestätigen'}</button><button className="btn" disabled={busy} onClick={() => setConfirm(null)}>Abbrechen</button></div>
</div></div>}
</>);
}

View file

@ -0,0 +1,107 @@
'use client';
import { Suspense, useEffect, useMemo, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert, Field, eur, INTERVAL } from '@/components/ui';
import type { Lic } from '@/components/Licenses';
interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: string; durationType: string; price: string | null; currency: string | null; planKey: string | null; modules: string[]; customerLimit: number | null; active: boolean }
interface ShopProduct { id: string; name: string; recurringCents: number; setupCents: number; priceBasis: string; interval: string; termMonths: number; provisioning: { programId?: number; productId?: number } }
interface Catalog { programs: { id: number; name: string }[]; products: CatalogProduct[]; shopProducts: ShopProduct[] }
interface Cust { id: string; name: string; customerNumber: string; status: string }
const DURATION: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'unbefristet' };
/** Lizenz an einen Kunden vergeben: mit Vertrag (normaler Bestellweg, Preis und Verlängerung aus dem Produkt) oder ohne Berechnung (direkt im Lizenzsystem). */
function Vergeben() {
const sp = useSearchParams(); const router = useRouter();
const [cat, setCat] = useState<Catalog | null>(null); const [custs, setCusts] = useState<Cust[]>([]); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
const [done, setDone] = useState<{ number: string } | null>(null);
const [org, setOrg] = useState(sp.get('org') ?? ''); const parentParam = sp.get('parent') ?? '';
const [mode, setMode] = useState<'contract' | 'free'>(parentParam ? 'free' : 'contract');
const [kind, setKind] = useState<'license' | 'trial' | 'addon'>(parentParam ? 'addon' : 'license');
const [program, setProgram] = useState(0); const [product, setProduct] = useState(0); const [parent, setParent] = useState(parentParam);
const [orgLicenses, setOrgLicenses] = useState<Lic[]>([]);
useEffect(() => {
api<Catalog>('GET', '/admin/licenses/catalog').then(setCat).catch((e) => setErr(errMsg(e)));
api<Cust[]>('GET', '/admin/customers').then((l) => setCusts(l.filter((c) => c.status === 'active'))).catch(() => undefined);
}, []);
useEffect(() => { if (org) api<Lic[]>('GET', `/licenses?org=${org}`).then(setOrgLicenses).catch(() => setOrgLicenses([])); else setOrgLicenses([]); }, [org]);
// Add-on aus der Detailseite: Programm der Basislizenz vorwählen
const parentLic = orgLicenses.find((l) => l.id === parent);
useEffect(() => { if (kind === 'addon' && parentLic && cat) { const p = cat.programs.find((x) => x.name === parentLic.program); if (p) setProgram(p.id); } }, [kind, parentLic, cat]);
const products = useMemo(() => (cat?.products ?? []).filter((p) => p.programId === program && (kind === 'addon' ? p.type === 'ADDON' : p.type !== 'ADDON')), [cat, program, kind]);
const sel = products.find((p) => p.id === product);
const bases = orgLicenses.filter((l) => !l.addon && !l.revoked);
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!org) { setErr('Bitte einen Kunden wählen.'); return; }
const f = new FormData(e.currentTarget); const v = (k: string) => String(f.get(k) ?? '').trim(); const n = (k: string) => (v(k) === '' ? undefined : Number(v(k)));
setBusy(true); setErr('');
try {
if (mode === 'contract') {
const r = await api<{ id: string; number: string }>('POST', '/orders', { orgId: org, note: v('note') || undefined, items: [{ productId: v('shopProduct'), quantity: 1, discountBp: Math.round((n('discount') ?? 0) * 100) }] });
setDone({ number: r.number });
} else {
const until = v('expiresAt');
const r = await api<{ id: string }>('POST', '/admin/licenses', {
orgId: org, kind, programId: program, productId: product, parentId: kind === 'addon' ? parent : undefined,
durationType: kind === 'trial' ? undefined : v('durationType') || sel?.durationType || 'YEAR', expiresAt: until && kind !== 'trial' ? new Date(`${until}T23:59:59`).toISOString() : undefined,
maxActivations: n('maxActivations'), customerLimit: kind === 'license' ? n('customerLimit') : undefined, keyPrefix: v('keyPrefix') || undefined, note: v('note') || undefined,
});
router.push(`/lizenzen/${r.id}`);
}
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (done) return (<>
<h1>Lizenz vergeben</h1>
<Alert kind="ok">Bestellung {done.number} wurde angelegt und wird bereitgestellt. Die Lizenz erscheint in wenigen Sekunden in der Übersicht und beim Kunden, der Vertrag unter „Verträge“.</Alert>
<div className="row"><Link className="btn primary" href="/lizenzen">Zur Übersicht</Link><Link className="btn" href="/bestellungen">Bestellungen ansehen</Link><button className="btn" onClick={() => setDone(null)}>Weitere Lizenz vergeben</button></div>
</>);
return (<>
<p><Link href="/lizenzen">← Alle Lizenzen</Link></p>
<h1>Lizenz vergeben</h1>
{err && <Alert kind="err">{err}</Alert>}
{!cat ? <p className="muted" role="status">{err ? '' : 'Lizenzsystem wird abgefragt …'}</p> : <form onSubmit={submit}>
<div className="card"><h2>Kunde</h2>
<Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setParent(''); }} required><option value="">Kunde wählen …</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>
<fieldset style={{ border: 0, padding: 0, margin: 0 }}><legend className="small muted">Abrechnung</legend>
<label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={mode === 'contract'} onChange={() => setMode('contract')} disabled={kind === 'addon' && !!parentParam} /> Mit Vertrag – Preis, Laufzeit und Verlängerung aus dem Produkt, Rechnung wie bei einer Bestellung</label>
<label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={mode === 'free'} onChange={() => setMode('free')} /> Ohne Berechnung – Test, Kulanz, intern (direkt im Lizenzsystem)</label>
</fieldset>
</div>
{mode === 'contract' ? <div className="card"><h2>Produkt</h2>
{cat.shopProducts.length === 0 ? <Alert kind="warn">Es gibt noch kein aktives Produkt, das eine Lizenz bereitstellt. Unter Produkte → „Aus Anbieter übernehmen“ anlegen.</Alert> : <>
<Field id="shopProduct" label="Produkt"><select id="shopProduct" name="shopProduct" required defaultValue=""><option value="" disabled>Produkt wählen …</option>{cat.shopProducts.map((p) => <option key={p.id} value={p.id}>{p.name} – {eur(p.recurringCents)} {INTERVAL[p.interval] ?? p.interval}{p.setupCents ? ` + ${eur(p.setupCents)} einmalig` : ''}</option>)}</select></Field>
<div className="cols"><Field id="discount" label="Rabatt in % (optional)"><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" /></Field>
<Field id="note" label="Notiz zur Bestellung (optional)"><input id="note" name="note" maxLength={500} /></Field></div>
<p className="small muted">Die Bestellung wird sofort freigegeben; Vertrag und Lizenz entstehen automatisch. Limits und Geräteanzahl lassen sich danach auf der Lizenzseite anpassen.</p>
</>}
</div> : <div className="card"><h2>Lizenz</h2>
<fieldset style={{ border: 0, padding: 0, margin: '0 0 16px' }}><legend className="small muted">Art</legend><div className="row">
{([['license', 'Lizenz'], ['trial', 'Test (einmal je Kunde und Programm)'], ['addon', 'Add-on zu einer Basislizenz']] as const).map(([k, l]) => <label key={k} className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={kind === k} onChange={() => { setKind(k); setProduct(0); }} /> {l}</label>)}
</div></fieldset>
{kind === 'addon' && <Field id="parent" label="Basislizenz des Kunden"><select id="parent" value={parent} onChange={(e) => setParent(e.target.value)} required><option value="">{org ? (bases.length ? 'Basislizenz wählen …' : 'Dieser Kunde hat keine Basislizenz') : 'Erst Kunde wählen'}</option>{bases.map((l) => <option key={l.id} value={l.id}>{l.program}{l.product ? ` · ${l.product}` : ''} ({l.keyMasked})</option>)}</select></Field>}
<div className="cols">
<Field id="program" label="Programm"><select id="program" value={program || ''} onChange={(e) => { setProgram(Number(e.target.value)); setProduct(0); }} required disabled={kind === 'addon' && !!parentLic}><option value="">Programm wählen …</option>{cat.programs.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></Field>
<Field id="product" label={kind === 'addon' ? 'Add-on-Produkt' : 'Produkt'}><select id="product" value={product || ''} onChange={(e) => setProduct(Number(e.target.value))} required><option value="">{program ? (products.length ? 'Produkt wählen …' : 'Keine passenden Produkte') : 'Erst Programm wählen'}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.groupName} – {p.name}{p.active ? '' : ' (inaktiv)'}</option>)}</select></Field>
</div>
{sel && <p className="small muted">Plan: {sel.planKey ?? '–'} · Module: {sel.modules.length ? sel.modules.join(', ') : '–'}{sel.type !== 'ADDON' ? ` · Kundenlimit: ${sel.customerLimit ?? 'unbegrenzt'}` : ''} · Laufzeit laut Produkt: {DURATION[sel.durationType] ?? sel.durationType}</p>}
{kind === 'trial' ? <p className="small muted">Ein Test läuft 30 Tage ab der ersten Aktivierung, mit allen Modulen des Produkts. Je Kunde und Programm ist nur ein Test möglich.</p> : <div className="cols">
<Field id="durationType" label="Laufzeit"><select id="durationType" name="durationType" key={sel?.id ?? 0} defaultValue={sel?.durationType && DURATION[sel.durationType] ? sel.durationType : 'YEAR'}>{Object.entries(DURATION).map(([k, l]) => <option key={k} value={k}>{l}</option>)}</select></Field>
<Field id="expiresAt" label="oder festes Ablaufdatum (optional)"><input id="expiresAt" name="expiresAt" type="date" /></Field>
</div>}
<div className="cols">
<Field id="maxActivations" label="Geräte gleichzeitig"><input id="maxActivations" name="maxActivations" type="number" min={1} max={1000} defaultValue={1} /></Field>
{kind === 'license' && <Field id="customerLimit" label="Kundenlimit (leer = laut Produkt)"><input id="customerLimit" name="customerLimit" type="number" min={0} /></Field>}
<Field id="keyPrefix" label="Editions-Präfix im Schlüssel (nur für Programme, die daran die Edition erkennen, z. B. Familytool)"><select id="keyPrefix" name="keyPrefix" defaultValue=""><option value="">keins</option><option value="PREMIUM">PREMIUM-…</option><option value="LIFETIME">LIFETIME…</option><option value="TRIAL">TRIAL-…</option></select></Field>
<Field id="note" label="Notiz (optional, max. 50 Zeichen)"><input id="note" name="note" maxLength={50} placeholder="z. B. Kulanz Ticket T-1003" /></Field>
</div>
</div>}
<div className="row"><button className="btn primary" type="submit" disabled={busy || (mode === 'contract' && cat.shopProducts.length === 0)}>{busy ? 'Bitte warten …' : mode === 'contract' ? 'Bestellung anlegen' : 'Lizenz vergeben'}</button><Link className="btn" href="/lizenzen">Abbrechen</Link></div>
</form>}
</>);
}
export default function Page() { return <Suspense><Vergeben /></Suspense>; }

View file

@ -0,0 +1,68 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, ResState, fmt } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { LicKind, type Lic } from '@/components/Licenses';
interface Summary { total: number; active: number; suspended: number; expired: number; expiring: number; unassigned: number }
type Filter = '' | 'active' | 'suspended' | 'expired' | 'expiring' | 'unassigned';
const soon = (d: string | null) => !!d && new Date(d).getTime() - Date.now() < 30 * 86400000 && new Date(d).getTime() > Date.now();
const devices = (l: Lic) => `${l.activationsUsed} / ${l.activationLimit ?? '∞'}`;
export default function Lizenzen() {
const { can } = useSession(); const staff = can('licenses.read'); const w = can('licenses.write');
const [list, setList] = useState<Lic[] | null>(null); const [sum, setSum] = useState<Summary | null>(null); const [err, setErr] = useState('');
const [filter, setFilter] = useState<Filter>(''); const [q, setQ] = useState(''); const [query, setQuery] = useState('');
const load = useCallback(() => {
const p = new URLSearchParams();
if (filter === 'expiring') p.set('expiring', '1'); else if (filter === 'unassigned') p.set('unassigned', '1'); else if (filter) p.set('state', filter);
if (query) p.set('q', query);
api<Lic[]>('GET', `/licenses${p.size ? `?${p}` : ''}`).then(setList).catch((e) => setErr(errMsg(e)));
}, [filter, query]);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (staff) api<Summary>('GET', '/admin/licenses/summary').then(setSum).catch(() => undefined); }, [staff]);
const tile = (f: Filter, n: number, label: string, warn = false) => (
<button type="button" className="card" onClick={() => setFilter(filter === f ? '' : f)} aria-pressed={filter === f} style={{ textAlign: 'left', cursor: 'pointer', outline: filter === f ? '2px solid var(--accent)' : undefined }}>
<div className="stat">{n}</div><p className="muted" style={{ margin: 0 }}>{label}{warn && n > 0 && <> <span className="badge warn"><span aria-hidden="true">▲</span>Prüfen</span></>}</p>
</button>);
return (<>
<div className="pagehead row between"><h1>{staff ? 'Lizenzen' : 'Meine Lizenzen'}</h1>{w && <Link className="btn primary" href="/lizenzen/neu">Lizenz vergeben</Link>}</div>
{err && <Alert kind="err">{err}</Alert>}
{staff && sum && <div className="grid" style={{ marginBottom: 16 }}>
{tile('', sum.total, 'Lizenzen gesamt')}{tile('active', sum.active, 'Aktiv')}{tile('expiring', sum.expiring, 'Laufen in 30 Tagen ab', true)}
{tile('suspended', sum.suspended, 'Gesperrt')}{tile('expired', sum.expired, 'Abgelaufen')}{sum.unassigned > 0 && tile('unassigned', sum.unassigned, 'Ohne Kunde', true)}
</div>}
{staff && <form className="row" style={{ marginBottom: 16, gap: 8 }} onSubmit={(e) => { e.preventDefault(); setQuery(q.trim()); }}>
<input aria-label="Suche nach Kunde, Kundennummer, Programm oder Lizenz-Nr." placeholder="Kunde, Kundennummer, Programm …" value={q} onChange={(e) => setQ(e.target.value)} style={{ maxWidth: 360 }} />
<button className="btn" type="submit">Suchen</button>
{(query || filter) && <button className="btn" type="button" onClick={() => { setQ(''); setQuery(''); setFilter(''); }}>Filter zurücksetzen</button>}
</form>}
{list === null ? <div className="card"><p className="muted" role="status">Wird geladen …</p></div>
: list.length === 0 ? <div className="card"><Empty title={filter || query ? 'Keine passenden Lizenzen' : 'Noch keine Lizenzen'}>{staff ? (w ? 'Über „Lizenz vergeben“ eine Lizenz an einen Kunden ausgeben.' : '') : 'Für Ihre Organisation sind noch keine Lizenzen hinterlegt.'}</Empty></div>
: staff ? (
<div className="card"><div className="tablewrap"><table><thead><tr><th>Lizenz</th><th>Kunde</th><th>Status</th><th>Gültig bis</th><th>Geräte</th><th>Vertrag</th></tr></thead><tbody>
{list.map((l) => <tr key={l.id}>
<td><Link href={`/lizenzen/${l.id}`}>{l.program ?? l.name}{l.product ? ` · ${l.product}` : ''}</Link><LicKind l={l} /><div className="mono small muted">{l.keyMasked}</div></td>
<td>{l.orgId ? <Link href={`/admin/kunden/${l.orgId}`}>{l.customerNumber} · {l.orgName}</Link> : <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span>}</td>
<td><ResState value={l.state} /></td>
<td>{l.validUntil ? <>{fmt(l.validUntil)}{soon(l.validUntil) && l.state === 'active' && <> <span className="badge warn"><span aria-hidden="true">▲</span>bald</span></>}</> : 'unbefristet'}</td>
<td>{devices(l)}</td>
<td>{l.contractId ? <Link href={`/vertraege/${l.contractId}`}>{l.contractNumber}</Link> : <span className="muted small">ohne Berechnung</span>}</td>
</tr>)}</tbody></table></div></div>
) : (
<div className="productgrid">{list.map((l) => (
<div className="productcard" key={l.id}>
<div className="row between"><h3><Link href={`/lizenzen/${l.id}`}>{l.program ?? l.name}</Link></h3><ResState value={l.state} /></div>
<div className="id">{l.product ?? l.edition}<LicKind l={l} /></div>
<div className="small muted">Gültig bis: {l.validUntil ? fmt(l.validUntil) : 'unbefristet'}</div>
<div className="small muted">Geräte: {devices(l)}</div>
<div style={{ marginTop: 4 }}><SecretField resourceId={l.id} compact /></div>
<div style={{ marginTop: 8 }}><Link className="btn small" href={`/lizenzen/${l.id}`}>Details und Geräte</Link></div>
</div>))}</div>
)}
</>);
}

View file

@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Field } from '@/components/ui';
export default function RechnungsExport() {
const today = new Date();
const jahresanfang = `${today.getFullYear()}-01-01`;
const heute = today.toISOString().slice(0, 10);
const [from, setFrom] = useState(jahresanfang);
const [to, setTo] = useState(heute);
const [status, setStatus] = useState('');
const qs = new URLSearchParams({ ...(from ? { from } : {}), ...(to ? { to } : {}), ...(status ? { status } : {}) }).toString();
return (<>
<div className="row between"><h1>Export für den Steuerberater</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
<p className="muted">Rechnungsjournal (CSV, für Excel/DATEV) und die zugehörigen Rechnungs-PDFs als Sammel-ZIP – für den gewählten Zeitraum. Entwürfe sind nie enthalten, nur tatsächlich ausgestellte Rechnungen und Stornorechnungen.</p>
<div className="card">
<div className="row">
<Field id="from" label="Von"><input id="from" type="date" value={from} onChange={(e) => setFrom(e.target.value)} /></Field>
<Field id="to" label="Bis"><input id="to" type="date" value={to} onChange={(e) => setTo(e.target.value)} /></Field>
<Field id="status" label="Status">
<select id="status" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Alle (offen, bezahlt, storniert)</option>
<option value="open">Nur offen</option>
<option value="paid">Nur bezahlt</option>
<option value="cancelled">Nur storniert</option>
</select>
</Field>
</div>
<div className="row" style={{ marginTop: 8 }}>
<a className="btn primary" href={`/api/admin/invoices/export.csv?${qs}`}>Rechnungsjournal (CSV) herunterladen</a>
<a className="btn" href={`/api/admin/invoices/export.zip?${qs}`}>Belege (ZIP mit PDFs) herunterladen</a>
<a className="btn" href={`/api/admin/invoices/export.datev?${qs}`} target="_blank" rel="noreferrer">DATEV-Buchungsstapel herunterladen</a>
</div>
<p className="muted small" style={{ marginTop: 12 }}>
Das Journal enthält je Rechnung Nummer, Datum, Kunde, Netto/USt/Brutto, effektiven USt-Satz, Status und Zahlungsdatum – Semikolon-getrennt mit Dezimalkomma, öffnet sich direkt in Excel.
Das ZIP enthält bis zu 500 Rechnungs-PDFs für den gewählten Zeitraum; bei mehr Rechnungen bitte den Zeitraum eingrenzen.
Der DATEV-Buchungsstapel (EXTF, direkt importierbar) braucht vorher hinterlegte Stammdaten unter <Link href="/einstellungen/firma">Einstellungen → Firma</Link>; fehlen sie, öffnet der Link statt der Datei eine Fehlermeldung mit den fehlenden Angaben.
</p>
</div>
</>);
}

View file

@ -4,5 +4,5 @@ import { useSession } from '@/lib/session';
import { InvoiceList } from '@/components/InvoiceList';
export default function Rechnungen() {
const { can } = useSession();
return (<><div className="row between"><h1>Rechnungen</h1>{can('invoices.read') && <Link className="btn" href="/rechnungen/faellig">Fällig</Link>}</div><InvoiceList /></>);
return (<><div className="row between"><h1>Rechnungen</h1>{can('invoices.read') && <div className="row"><Link className="btn" href="/rechnungen/export">Export (Steuerberater)</Link><Link className="btn" href="/rechnungen/faellig">Fällig</Link></div>}</div><InvoiceList /></>);
}

View file

@ -4,13 +4,15 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, CUSTOMER_ACTIONS } from '@/components/ui';
import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, eur, CUSTOMER_ACTIONS } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { uuid } from '@/lib/uuid';
import { HostingPanel, UsageBars } from '@/components/HostingPanel';
interface Det { id: string; type: string; name: string; state: string; orgId: string | null; validFrom: string | null; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; missing: boolean; instance?: string; connector?: string;
canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; domains: DomainRec[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
interface DomainRec { id: string; domain: string; termMonths: number | null; registered: boolean; procurement?: string; sellGrossCents?: number | null }
const PROCUREMENT: Record<string, string> = { open: 'noch nicht bestellt', ordered: 'beim Registrar bestellt', external: 'extern verwaltet' };
const LIMIT_LABEL: Record<string, string> = { users: 'Benutzer', storageMb: 'Speicher (MB)' };
interface PanelCreds { canReset: boolean; set: boolean; setAt: string | null }
export default function Ressource() {
@ -74,6 +76,12 @@ export default function Ressource() {
<div><dt className="muted small">Gerät</dt><dd className="mono" style={{ margin: 0 }}>{d.details.activation.hardwareIdMasked}</dd></div></> : d.type === 'license' ? <div><dt className="muted small">Aktivierung</dt><dd style={{ margin: 0 }}>Noch nicht aktiviert</dd></div> : null}
</dl>
<p className="small muted" style={{ marginTop: 16 }}>Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}</p></div>
{d.domains.length > 0 && <div className="card"><h2>Domains</h2>
<div className="tablewrap"><table><thead><tr><th>Domain</th><th>Laufzeit</th><th>Status</th>{staff && <th>Preis (brutto)</th>}</tr></thead><tbody>
{d.domains.map((x) => <tr key={x.id}><td>{x.domain}</td><td>{x.termMonths ? `${x.termMonths} Monate` : '–'}</td>
<td>{staff ? PROCUREMENT[x.procurement ?? ''] ?? x.procurement : x.registered ? 'Registriert' : 'In Bearbeitung'}</td>
{staff && <td>{x.sellGrossCents != null ? eur(x.sellGrossCents) : '–'}</td>}</tr>)}</tbody></table></div>
{staff && <p className="small muted" style={{ marginTop: 8 }}><Link href="/admin/domains">Zur Domain-Aufstellung</Link></p>}</div>}
{d.type === 'hosting_account' && <UsageBars usage={d.usage} limits={d.limits} />}
{(d.hasChildren || d.canLogin) && <HostingPanel resourceId={d.id} canLogin={!!d.canLogin} />}
{d.canReveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}

View file

@ -1,21 +1,34 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, ContractStatusBadge, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
import { Alert, ContractStatusBadge, Field, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean;
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number } } }
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean; canEdit?: boolean;
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number }; basis: 'net' | 'gross'; unitRecurringCents: number; unitSetupCents: number; adjusted?: { at: string; reason: string } } }
const toCents = (v: string): number => { const t = v.trim(); const n = Number(t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t); return Number.isFinite(n) ? Math.round(n * 100) : NaN; };
const centsIn = (c: number): string => (c / 100).toFixed(2).replace('.', ',');
export default function Vertrag() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null);
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false);
const load = useCallback(() => api<D>('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function cancel(immediate: boolean) { try { const r = await api<{ cancelEffectiveAt: string }>('POST', `/contracts/${id}/cancel`, { immediate }); setConfirm(null); setMsg({ k: 'ok', t: `Die Kündigung wurde vorgemerkt und wird wirksam am ${fmt(r.cancelEffectiveAt)}.` }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function revoke() { try { await api('POST', `/contracts/${id}/cancel/revoke`); setMsg({ k: 'ok', t: 'Die Kündigung wurde zurückgenommen.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => String(f.get(k) ?? '');
const recurringCents = toCents(g('recurring')); const setupCents = toCents(g('setup'));
if (Number.isNaN(recurringCents) || Number.isNaN(setupCents)) { setMsg({ k: 'err', t: 'Bitte die Preise als Zahl angeben, z. B. 12,50.' }); return; }
setSaving(true);
try {
await api('PATCH', `/admin/contracts/${id}`, { recurringCents, setupCents, basis: g('basis'), interval: g('interval'), discountBp: Math.round(Number(g('discount').replace(',', '.')) * 100),
termEnd: g('termEnd') || null, renewal: g('renewal'), renewalTermMonths: Number(g('renewalTermMonths')), noticeDays: Number(g('noticeDays')), reason: g('reason') });
setEditing(false); setMsg({ k: 'ok', t: 'Der Vertrag wurde angepasst.' }); void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setSaving(false); }
}
const priv = d.price.customerType === 'private'; const pending = !!d.cancelEffectiveAt && ['active', 'suspended'].includes(d.status);
return (<>
<p><Link href="/vertraege">← Alle Verträge</Link></p>
@ -30,8 +43,26 @@ export default function Vertrag() {
<div><dt className="muted small">Verlängerung</dt><dd style={{ margin: 0 }}>{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}</dd></div>
<div><dt className="muted small">Kündigungsfrist</dt><dd style={{ margin: 0 }}>{d.noticeDays} Tage</dd></div>
<div><dt className="muted small">Abrechnung</dt><dd style={{ margin: 0 }}>{INTERVAL[d.price.interval]}</dd></div></dl>
<p className="small muted" style={{ marginTop: 16 }}>Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.</p>
<p className="small muted" style={{ marginTop: 16 }}>{d.price.adjusted ? `Preis zuletzt angepasst am ${fmt(d.price.adjusted.at)}.` : `Preisstand bei Bestellung (Produkt ${d.price.sku}, Version ${d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.`}</p>
{d.resourceId && <p><Link href={`/ressourcen/${d.resourceId}`}>Zugehörige Ressource ansehen</Link></p>}</div>
{d.canEdit && <div className="card"><h2>Vertrag anpassen</h2>
{!editing ? <><p className="muted small">Nur für Superadmins. Preis und Konditionen werden mit Begründung im Audit-Protokoll festgehalten.</p><button className="btn" onClick={() => setEditing(true)}>Anpassen</button></> : (
<form onSubmit={save}>
<div className="cols">
<Field id="ce-rec" label="Preis je Abrechnung (€)" hint={`Bisher: ${centsIn(d.price.unitRecurringCents)}`}><input id="ce-rec" name="recurring" inputMode="decimal" required defaultValue={centsIn(d.price.unitRecurringCents)} /></Field>
<Field id="ce-basis" label="Preis ist"><select id="ce-basis" name="basis" defaultValue={d.price.basis}><option value="net">netto (zzgl. USt)</option><option value="gross">brutto (inkl. USt)</option></select></Field>
<Field id="ce-int" label="Abrechnung"><select id="ce-int" name="interval" defaultValue={d.price.interval}>{Object.entries(INTERVAL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="ce-setup" label="Einrichtung einmalig (€)"><input id="ce-setup" name="setup" inputMode="decimal" required defaultValue={centsIn(d.price.unitSetupCents)} /></Field>
<Field id="ce-disc" label="Rabatt (%)"><input id="ce-disc" name="discount" inputMode="decimal" required defaultValue={String(d.price.discountBp / 100).replace('.', ',')} /></Field>
<Field id="ce-end" label="Laufzeit bis" hint="Leer = unbefristet"><input id="ce-end" name="termEnd" type="date" defaultValue={d.termEnd ? new Date(d.termEnd).toLocaleDateString('sv-SE') : ''} /></Field>
<Field id="ce-ren" label="Verlängerung"><select id="ce-ren" name="renewal" defaultValue={d.renewal}><option value="auto">automatisch</option><option value="none">keine</option></select></Field>
<Field id="ce-rtm" label="Verlängerung um (Monate)"><input id="ce-rtm" name="renewalTermMonths" type="number" min={0} max={120} required defaultValue={d.renewalTermMonths} /></Field>
<Field id="ce-nd" label="Kündigungsfrist (Tage)"><input id="ce-nd" name="noticeDays" type="number" min={0} max={365} required defaultValue={d.noticeDays} /></Field>
</div>
<Field id="ce-reason" label="Grund der Anpassung"><input id="ce-reason" name="reason" required minLength={3} maxLength={300} placeholder="z. B. Unkostenpreis vereinbart" /></Field>
<div className="row"><button className="btn primary" type="submit" disabled={saving}>{saving ? 'Wird gespeichert …' : 'Speichern'}</button><button className="btn" type="button" onClick={() => setEditing(false)}>Abbrechen</button></div>
</form>)}
</div>}
{(d.canCancel || pending) && <div className="card"><h2>Kündigung</h2>
{confirm ? (<div role="alertdialog" aria-labelledby="cc"><p id="cc"><strong>{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'}</strong> {confirm === 'immediate' ? 'Die Ressource wird gesperrt.' : 'Die Kündigung wird zum nächstmöglichen Termin wirksam; bis dahin läuft der Vertrag weiter.'} Die Aktion wird protokolliert.</p>
<div className="row"><button className="btn danger" onClick={() => cancel(confirm === 'immediate')}>Ja, kündigen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)

View file

@ -0,0 +1,49 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert } from '@/components/ui';
interface Info { contractNumber: string; orgName: string; productName: string; termEnd: string }
function Decision() {
const sp = useSearchParams();
const token = sp.get('token') ?? '';
const preselect = sp.get('action') === 'cancel' ? 'cancel' : sp.get('action') === 'keep' ? 'keep' : null;
const [info, setInfo] = useState<Info | null>(null);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<'keep' | 'cancel' | null>(null);
useEffect(() => {
if (!token) { setErr('Der Link ist unvollständig.'); return; }
api<Info>('GET', `/contracts/renewal-decision?token=${encodeURIComponent(token)}`).then(setInfo).catch((e) => setErr(errMsg(e)));
}, [token]);
async function decide(action: 'keep' | 'cancel') {
setBusy(true); setErr('');
try { await api('POST', '/contracts/renewal-decision', { token, action }); setDone(action); }
catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
}
return (<main id="main" className="center"><div className="auth card" style={{ maxWidth: 480 }}>
<h1>Vertragsentscheidung</h1>
{err && <Alert kind="err">{err}</Alert>}
{done ? (
<Alert kind="ok">
{done === 'keep' ? 'Vielen Dank! Der Vertrag wird weitergeführt.' : 'Die Kündigung wurde erfasst. Unser Team kümmert sich um die Abmeldung und meldet sich bei Rückfragen.'}
</Alert>
) : info ? (<>
<p className="muted">
Vertrag <strong>{info.contractNumber}</strong> ({info.productName}) für <strong>{info.orgName}</strong> läuft am{' '}
<strong>{new Date(info.termEnd).toLocaleDateString('de-DE')}</strong> aus. Möchten Sie ihn behalten oder kündigen?
</p>
<div className="row" style={{ marginTop: 16 }}>
<button className="btn primary" disabled={busy} autoFocus={preselect === 'keep'} onClick={() => decide('keep')}>{busy ? '…' : 'Vertrag behalten'}</button>
<button className="btn danger" disabled={busy} autoFocus={preselect === 'cancel'} onClick={() => decide('cancel')}>{busy ? '…' : 'Vertrag kündigen'}</button>
</div>
<p className="muted small" style={{ marginTop: 16 }}>„Behalten" verlängert den Vertrag automatisch um eine weitere Laufzeit. „Kündigen" leitet die Abmeldung ein.</p>
</>) : !err && <p className="muted" role="status">Wird geladen …</p>}
</div></main>);
}
export default function Page() { return <Suspense><Decision /></Suspense>; }

View file

@ -4,9 +4,9 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, eur, fmt } from '@/components/ui';
import { Alert, Empty, Field, eur } from '@/components/ui';
interface Rec { id: string; domain: string; tld: string; orgId: string | null; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; orderedRef: string | null; note: string | null }
interface Rec { id: string; domain: string; tld: string; orgId: string | null; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; note: string | null }
interface Res { id: string; name: string; type: string; orgId: string | null }
const PROC = { open: 'Bei KCS zu bestellen', ordered: 'Bei KCS bestellt', external: 'Anderer Anbieter' } as const;
const money = (c: number | null) => c === null ? '–' : eur(c);
@ -14,8 +14,8 @@ const csvNum = (c: number | null) => c === null ? '' : (c / 100).toFixed(2).repl
const csvCell = (v: string | number | null) => `"${String(v ?? '').replace(/"/g, '""')}"`;
/** CSV für Excel (Semikolon, UTF-8 mit BOM): Bestellliste bzw. Gesamtaufstellung. */
function download(rows: Rec[], name: string) {
const head = ['Domain', 'Endung', 'Laufzeit (Monate)', 'Kunde', 'Kundennr.', 'EK brutto', 'EK netto', 'Setup EK brutto', 'VK brutto', 'VK netto', 'USt %', 'Gewinn netto', 'Status', 'bestellt am', 'Bestellnr. KCS', 'Notiz'];
const lines = rows.map((r) => [r.domain, r.tld, r.termMonths, r.customer, r.customerNumber, csvNum(r.costGrossCents), csvNum(r.costNetCents), csvNum(r.setupCostCents), csvNum(r.sellGrossCents), csvNum(r.sellNetCents), r.taxBp / 100, csvNum(r.profitNetCents), PROC[r.procurement], r.orderedAt ? new Date(r.orderedAt).toLocaleDateString('de-DE') : '', r.orderedRef, r.note].map(csvCell).join(';'));
const head = ['Domain', 'Endung', 'Laufzeit (Monate)', 'Kunde', 'Kundennr.', 'EK brutto', 'EK netto', 'Setup EK brutto', 'VK brutto', 'VK netto', 'USt %', 'Gewinn netto', 'Status', 'bestellt am', 'Notiz'];
const lines = rows.map((r) => [r.domain, r.tld, r.termMonths, r.customer, r.customerNumber, csvNum(r.costGrossCents), csvNum(r.costNetCents), csvNum(r.setupCostCents), csvNum(r.sellGrossCents), csvNum(r.sellNetCents), r.taxBp / 100, csvNum(r.profitNetCents), PROC[r.procurement], r.orderedAt ? new Date(r.orderedAt).toLocaleDateString('de-DE') : '', r.note].map(csvCell).join(';'));
const url = URL.createObjectURL(new Blob(['' + [head.map(csvCell).join(';'), ...lines].join('\r\n')], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
@ -44,7 +44,7 @@ export function DomainRecords({ orgId }: { orgId?: string }) {
{list.map((r) => (<tr key={r.id}><td><span className="mono">{r.domain}</span>{r.termMonths && <div className="muted small">{r.termMonths} Mon.{r.setupCostCents ? ` · Setup ${eur(r.setupCostCents)}` : ''}</div>}{r.sellNetCents === null && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{r.costGrossCents === null ? 'Endung nicht in Preisliste' : 'Aufschlag fehlt'}</span></div>}</td>
{!orgId && <td>{r.customer ? <>{r.customer}<div className="muted small">{r.customerNumber}</div></> : <span className="muted small">–</span>}</td>}<td>{money(r.costGrossCents)}<div className="muted small">inkl. USt</div></td><td>{money(r.costNetCents)}</td><td><strong>{money(r.sellGrossCents)}</strong><div className="muted small">inkl. {r.taxBp / 100} % USt</div></td><td>{money(r.sellNetCents)}</td><td>{money(r.profitNetCents)}</td>
<td>{w ? <><select aria-label={`Status ${r.domain}`} value={r.procurement} onChange={(e) => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { procurement: e.target.value }))}>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select>
{r.procurement === 'ordered' && <div className="row" style={{ marginTop: 4 }}><input aria-label="Bestellnummer bei KCS" placeholder="Bestellnr." style={{ width: 120 }} value={ref[r.id] ?? r.orderedRef ?? ''} onChange={(e) => setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] !== undefined && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedRef: ref[r.id] || null }))} />{r.orderedAt && <span className="muted small">{fmt(r.orderedAt)}</span>}</div>}</> : PROC[r.procurement]}</td>
{r.procurement === 'ordered' && <div className="row small" style={{ marginTop: 4 }}><label htmlFor={`oa-${r.id}`} className="muted">bestellt am</label><input id={`oa-${r.id}`} type="date" style={{ width: 150 }} value={ref[r.id] ?? (r.orderedAt ? new Date(r.orderedAt).toLocaleDateString('sv-SE') : '')} onChange={(e) => setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedAt: ref[r.id] }))} /></div>}</> : PROC[r.procurement]}</td>
{w && <td><div className="row">{r.orgId && <Link className="btn small" href={`/tickets?new=1&org=${r.orgId}&domain=${r.id}`}>Ticket erstellen</Link>}<button className="btn small" title="Einkauf und Verkauf mit der aktuellen Preisliste neu berechnen" onClick={() => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { reprice: true }), `${r.domain} neu berechnet.`)}>Neu berechnen</button><button className="btn small" onClick={() => confirm(`${r.domain} aus der Aufstellung entfernen?`) && run(() => api('DELETE', `/admin/domain-records/${r.id}`))}>Entfernen</button></div></td>}</tr>))}
<tr><th>Summe ({list.length})</th>{!orgId && <th></th>}<th>{eur(sum((r) => r.costGrossCents))}</th><th>{eur(sum((r) => r.costNetCents))}</th><th>{eur(sum((r) => r.sellGrossCents))}</th><th>{eur(sum((r) => r.sellNetCents))}</th><th>{eur(sum((r) => r.profitNetCents))}</th><th colSpan={w ? 2 : 1}></th></tr></tbody></table></div>
<div className="row"><button className="btn primary" disabled={open.length === 0} onClick={() => download(open, `KCS-Bestellliste-${new Date().toISOString().slice(0, 10)}.csv`)}>Bestellliste für KCS (offen: {open.length})</button><button className="btn" onClick={() => download(list, `Domain-Aufstellung-${new Date().toISOString().slice(0, 10)}.csv`)}>Gesamte Aufstellung als CSV</button></div></>}

View file

@ -0,0 +1,16 @@
'use client';
/** Gemeinsame Typen und Anzeigebausteine der Lizenzverwaltung (Übersicht, Detail, Vergabe). */
export interface Lic {
id: string; name: string; state: string; validFrom: string | null; validUntil: string | null; syncedAt: string; missing: boolean; orgId: string | null; orgName: string | null; customerNumber: string | null;
contractId: string | null; contractNumber: string | null; program: string | null; product: string | null; edition: string | null; keyMasked: string | null;
activationsUsed: number; activationLimit: number | null; trial: boolean; trialPending: boolean; addon: boolean; revoked: boolean;
}
/** Art der Lizenz als kleines Badge (Test, Add-on, Widerrufen). */
export function LicKind({ l }: { l: Pick<Lic, 'trial' | 'trialPending' | 'addon' | 'revoked'> }) {
return (<>
{l.trial && <> <span className="badge info">{l.trialPending ? 'Test (startet bei Aktivierung)' : 'Test'}</span></>}
{l.addon && <> <span className="badge info">Add-on</span></>}
{l.revoked && <> <span className="badge err"><span aria-hidden="true">✕</span>Widerrufen</span></>}
</>);
}

View file

@ -2,10 +2,12 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { api, setCsrf, ApiError } from './api';
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
export interface Me {
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
organizations: { id: string; name: string; customerNumber: string; role: string }[];
impersonating: Impersonating | null;
}
interface Ctx { me: Me | null; loading: boolean; reload: () => Promise<Me | null>; can: (p: string) => boolean }
const C = createContext<Ctx>({ me: null, loading: true, reload: async () => null, can: () => false });

View file

@ -1,15 +1,26 @@
import { ChannelType, Client, GatewayIntentBits, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js';
import { ChannelType, Client, GatewayIntentBits, OverwriteType, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Guild, type Message, type OverwriteResolvable, type TextChannel } from 'discord.js';
import { randomUUID } from 'node:crypto';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
import { config } from '@kc/platform/config';
import { audit } from '@kc/platform/audit';
import { hasFeature } from '@kc/platform/license';
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null }
interface DiscordSettings {
enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null;
/** Kategorie-Modus: je offenem Ticket ein eigener Kanal in dieser Kategorie (hat Vorrang vor ticketChannelId/Threads). */
ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string[];
}
async function loadSettings(): Promise<DiscordSettings> {
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
const s = rows[0];
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null };
return {
enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null,
ticketCategoryId: s?.ticket_category_id ?? null, ticketLogChannelId: s?.ticket_log_channel_id ?? null,
supportRoleIds: String(s?.support_role_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean),
};
}
const notify = (event: string, extra: Record<string, unknown>, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key });
async function nextTicketNumber(): Promise<string> {
@ -63,7 +74,8 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, customer.id, 'customer', body]);
await notify('ticket.created', { number, subject: subject.slice(0, 100) }, `discord-cmd:${ticketId}`);
await postTicketMessage(ticketId, body, 'Kunde');
await i.editReply({ content: `Ticket ${number} wurde erstellt. Den zugehörigen Thread findest du weiter unten im Kanal.` });
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: ch ? `Ticket ${number} wurde erstellt: <#${ch}>` : `Ticket ${number} wurde erstellt.` });
return;
}
@ -81,7 +93,9 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
// Von Personal angelegt: wartet auf den Kunden, wie beim Anlegen über die Web-Oberfläche.
await pool.query('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', [ticketId, number, org.id, subject, 'pending_customer', 'normal', staff.id]);
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, staff.id, 'staff', body]);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.` });
await postTicketMessage(ticketId, body, 'Support');
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.${ch ? ` <#${ch}>` : ''}` });
}
/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */
async function handleAssign(i: ChatInputCommandInteraction): Promise<void> {
@ -100,7 +114,8 @@ async function handleClose(i: ChatInputCommandInteraction): Promise<void> {
const t = await ticketForThread(i.channelId);
if (!t) { await i.reply({ content: 'Dieser Befehl funktioniert nur innerhalb eines Ticket-Threads.', ephemeral: true }); return; }
await pool.query("UPDATE tickets SET status = 'closed', closed_at = UTC_TIMESTAMP(3) WHERE id = ?", [t.ticket_id]);
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.` });
const categoryMode = !!(await loadSettings()).ticketCategoryId;
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.${categoryMode ? ' Dieser Kanal wird innerhalb einer Minute entfernt, der Verlauf bleibt im Kundencenter.' : ''}` });
}
async function handle(i: ChatInputCommandInteraction): Promise<void> {
@ -120,28 +135,143 @@ async function handle(i: ChatInputCommandInteraction): Promise<void> {
}
}
/** Nachricht in einem Ticket-Thread von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten im Thread werden ignoriert (Web-Oberfläche bleibt die Quelle). */
/** Nachricht im Ticket-Thread bzw. Ticket-Kanal übernehmen: vom Kunden als Kundenantwort, vom Personal (verknüpftes Konto)
* als Support-Antwort wie im Kundencenter (Status "wartet auf Kunde", Mail an den Ersteller). Andere Nachrichten werden ignoriert. */
async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise<void> {
if (msg.author.bot || !msg.channel.isThread()) return;
const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id, tk.status FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [msg.channel.id]).then(([r]: any) => r[0]);
if (msg.author.bot || !msg.inGuild()) return;
const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id, tk.status, tk.created_by FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [msg.channel.id]).then(([r]: any) => r[0]);
if (!t) return;
if (t.status === 'closed') { await msg.reply('Dieses Ticket ist geschlossen. Bitte im Kundencenter ein neues Ticket eröffnen oder den Befehl `/ticket` nutzen.').catch(() => undefined); return; }
const u = await pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = 'customer'", [msg.author.id]).then(([r]: any) => r[0]);
if (!u) return; // unbekannt oder Personal: Web-Oberfläche bleibt die Quelle für Personal-Antworten
const member = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [u.id, t.org_id]).then(([r]: any) => r[0]);
if (!member) return;
const body = msg.content.trim().slice(0, 10000);
if (!body) return;
const staff = await linkedUser(msg.author.id, 'staff');
if (staff) {
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.ticket_id, staff.id, 'staff', body]);
await pool.query("UPDATE tickets SET status = 'pending_customer', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.ticket_id]);
await audit({ actorType: 'user', actorId: staff.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: t.ticket_id, after: { internalNote: false, via: 'discord' } });
const creator = await pool.query('SELECT email, name FROM users WHERE id = ?', [t.created_by]).then(([r]: any) => r[0]);
if (creator) await enqueue('mail.template', { to: creator.email, key: 'ticket_message', vars: { name: creator.name, number: t.number, subject: t.subject, ticketLink: ticketLink(t.ticket_id) } }, { idempotencyKey: `mail:discord-staff:${msg.id}` });
log(`Discord: Support-Antwort zu Ticket ${t.number} übernommen.`);
return;
}
const u = await pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = 'customer'", [msg.author.id]).then(([r]: any) => r[0]);
if (!u) return; // Discord-Konto ohne Verknüpfung: nicht zuordenbar
const member = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [u.id, t.org_id]).then(([r]: any) => r[0]);
if (!member) return;
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.ticket_id, u.id, 'customer', body]);
await pool.query("UPDATE tickets SET status = 'pending_staff', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.ticket_id]);
await notify('ticket.message', { number: t.number }, `discord-in:${msg.id}`);
log(`Discord: Antwort im Thread zu Ticket ${t.number} übernommen.`);
}
/** Stellt sicher, dass ein privater Thread für das Ticket existiert (nur wenn der Kunde Discord verknüpft hat), und postet die Nachricht hinein. */
// ---- Kategorie-Modus: je offenem Ticket ein eigener Kanal ------------------------------------------------------------
// Sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften Mitglieder des Kunden (@everyone: nein).
// Die Kategorie zeigt damit genau die offenen Tickets; beim Schließen wird der Kanal gelöscht (Verlauf bleibt im Kundencenter).
const CHANNEL_PERMS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.AttachFiles];
const BOT_PERMS = [...CHANNEL_PERMS, PermissionFlagsBits.ManageChannels];
const slug = (v: string) => v.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const ticketLink = (id: string) => `${config.baseUrl}/tickets/${id}`;
/** Discord-Konten der Kunden-Mitglieder (nur verknüpfte, aktive Benutzer der Organisation). */
const orgDiscordIds = (orgId: string) => pool.query("SELECT u.discord_user_id FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? AND u.kind = 'customer' AND u.status = 'active' AND u.discord_user_id IS NOT NULL", [orgId])
.then(([r]: any) => (r as { discord_user_id: string }[]).map((x) => x.discord_user_id));
function overwrites(guild: Guild, s: DiscordSettings, customerIds: string[]): OverwriteResolvable[] {
return [
{ id: guild.roles.everyone.id, type: OverwriteType.Role, deny: [PermissionFlagsBits.ViewChannel] },
{ id: guild.client.user.id, type: OverwriteType.Member, allow: BOT_PERMS },
...s.supportRoleIds.map((id) => ({ id, type: OverwriteType.Role, allow: CHANNEL_PERMS })),
...customerIds.map((id) => ({ id, type: OverwriteType.Member, allow: CHANNEL_PERMS })),
];
}
/** Seriell je Ticket, damit Abgleich und Auftrag nicht gleichzeitig zwei Kanäle für dasselbe Ticket anlegen. */
const locks = new Map<string, Promise<unknown>>();
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.catch(() => undefined).then(fn);
locks.set(key, next);
void next.finally(() => { if (locks.get(key) === next) locks.delete(key); }).catch(() => undefined);
return next;
}
async function fetchChannel(id: string): Promise<TextChannel | null> {
const ch = await client!.channels.fetch(id).catch((e) => { if ((e as { code?: number }).code === 10003) return null; throw e; }); // 10003 = Unknown Channel
return ch && ch.type === ChannelType.GuildText ? ch : null;
}
/** Liefert den Kanal des Tickets und legt ihn bei Bedarf an (mit Kopfzeile und, falls gewünscht, der Eröffnungsnachricht). */
async function ensureTicketChannel(s: DiscordSettings, ticketId: string, withOpening: boolean): Promise<{ channel: TextChannel; created: boolean } | null> {
return serial(ticketId, async () => {
const t = await pool.query('SELECT tk.id, tk.number, tk.subject, tk.status, tk.org_id, o.name AS org_name, o.customer_number FROM tickets tk JOIN organizations o ON o.id = tk.org_id WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]);
if (!t || t.status === 'closed') return null;
const known = await pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined);
if (known) {
const ch = await fetchChannel(known);
if (ch) return { channel: ch, created: false };
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]); // in Discord gelöscht: neu anlegen
}
const category = await client!.channels.fetch(s.ticketCategoryId!);
if (!category || category.type !== ChannelType.GuildCategory) throw new Error('Ticket-Kategorie nicht gefunden oder keine Kategorie');
const customerIds = await orgDiscordIds(t.org_id);
const channel = await category.guild.channels.create({
name: `${slug(t.number)}-${slug(t.org_name)}`.slice(0, 100), type: ChannelType.GuildText, parent: category.id,
topic: `${t.number} · ${t.subject}`.slice(0, 1024), permissionOverwrites: overwrites(category.guild, s, customerIds), reason: `Ticket ${t.number}`,
});
await pool.query('INSERT INTO ticket_discord_threads (ticket_id, thread_id) VALUES (?,?)', [ticketId, channel.id]);
await channel.send({ content: `**${t.number} · ${t.subject}**\nKunde: ${t.org_name} (${t.customer_number})\nIm Kundencenter: ${ticketLink(t.id)}\n_Nachrichten hier werden ins Ticket übernommen. Antworten des Supports gehen dem Kunden zusätzlich per Mail zu._`.slice(0, 2000), allowedMentions: { parse: [] } });
if (withOpening) {
const first = await pool.query('SELECT m.body, m.author_kind FROM ticket_messages m WHERE m.ticket_id = ? AND m.internal_note = 0 ORDER BY m.created_at LIMIT 1', [ticketId]).then(([r]: any) => r[0]);
if (first) await channel.send({ content: `**${first.author_kind === 'staff' ? 'Support' : 'Kunde'}:** ${first.body}`.slice(0, 2000), allowedMentions: { parse: [] } });
}
return { channel, created: true };
});
}
/** Ergänzt Kunden-Mitglieder, die ihr Discord erst nach dem Anlegen des Kanals verknüpft haben (nur wenn etwas fehlt). */
async function grantCustomers(channel: TextChannel, orgId: string): Promise<void> {
for (const id of await orgDiscordIds(orgId)) {
if (!channel.permissionOverwrites.cache.has(id)) await channel.permissionOverwrites.create(id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, AttachFiles: true }, { type: OverwriteType.Member, reason: 'Kunde hat Discord verknüpft' });
}
}
/** Abgleich (minütlich): Kanäle für offene Tickets anlegen, Kanäle geschlossener Tickets löschen, Support-Meldekanal anlegen. */
export async function reconcileTicketChannels(log: (m: string) => void): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (!s.ticketCategoryId) return;
if (!s.ticketLogChannelId) {
const category = await client.channels.fetch(s.ticketCategoryId);
if (category?.type === ChannelType.GuildCategory) {
const ch = await category.guild.channels.create({ name: 'ticket-log', type: ChannelType.GuildText, parent: category.id, topic: 'Meldungen zu neuen Tickets und Antworten (nur Support)', permissionOverwrites: overwrites(category.guild, s, []), reason: 'Ticket-Meldungen nur für den Support' });
await pool.query('UPDATE discord_settings SET ticket_log_channel_id = ? WHERE id = 1', [ch.id]);
log(`Discord: Support-Kanal #ticket-log angelegt (${ch.id}).`);
}
}
// Geschlossene Tickets: Kanal entfernen
const [closed] = await pool.query("SELECT tt.ticket_id, tt.thread_id, tk.number FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tk.status = 'closed'") as any;
for (const r of closed) {
await serial(r.ticket_id, async () => {
const ch = await fetchChannel(r.thread_id);
if (ch) await ch.delete(`Ticket ${r.number} geschlossen`);
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [r.ticket_id]);
});
log(`Discord: Kanal zu Ticket ${r.number} entfernt (geschlossen).`);
}
// Offene Tickets ohne Kanal (z. B. aus Mail-Eingang oder Vertragsverlängerung). Erst nach 2 Minuten, damit der
// Auftrag zur Eröffnungsnachricht zuerst greift und die Nachricht nicht doppelt erscheint.
const [open] = await pool.query("SELECT tk.id, tk.org_id, tt.thread_id FROM tickets tk LEFT JOIN ticket_discord_threads tt ON tt.ticket_id = tk.id WHERE tk.status <> 'closed' AND (tt.thread_id IS NOT NULL OR tk.created_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE))") as any;
for (const r of open) {
const res = await ensureTicketChannel(s, r.id, true);
if (res && !res.created) await grantCustomers(res.channel, r.org_id);
}
}
/** Nachricht ins Ticket in Discord übernehmen. Kategorie-Modus: Kanal je Ticket (legt ihn bei Bedarf an).
* Sonst (Altmodus): privater Thread im Ticket-Kanal, nur wenn der Ersteller Discord verknüpft hat. */
export async function postTicketMessage(ticketId: string, body: string, authorLabel: string): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (s.ticketCategoryId) {
const res = await ensureTicketChannel(s, ticketId, false);
if (!res) return; // Ticket geschlossen oder unbekannt
const t = await pool.query('SELECT org_id FROM tickets WHERE id = ?', [ticketId]).then(([r]: any) => r[0]);
if (t && !res.created) await grantCustomers(res.channel, t.org_id);
await res.channel.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000), allowedMentions: { parse: [] } });
return;
}
if (!s.ticketChannelId) return;
const t = await pool.query('SELECT tk.number, tk.subject, u.discord_user_id FROM tickets tk JOIN users u ON u.id = tk.created_by WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]);
if (!t?.discord_user_id) return; // Kunde hat kein Discord verknüpft
@ -160,6 +290,8 @@ export async function postTicketMessage(ticketId: string, body: string, authorLa
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Ticket-Thread nicht gefunden');
await ch.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000) });
}
/** Kanal-ID des Tickets in Discord (Thread oder Kanal), falls vorhanden. */
export const ticketChannelId = (ticketId: string) => pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined);
async function connect(s: DiscordSettings, log: (m: string) => void): Promise<void> {
client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
@ -182,8 +314,8 @@ async function connect(s: DiscordSettings, log: (m: string) => void): Promise<vo
/** Prüft, ob sich Token/Server/Aktivierung geändert haben, und verbindet bei Bedarf neu. Für periodischen Aufruf gedacht. */
export async function syncDiscord(log: (m: string) => void): Promise<void> {
const s = await loadSettings();
cachedEnabled = s.enabled && !!s.token;
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
cachedEnabled = s.enabled && !!s.token && (await hasFeature('discord')); // Discord-Bot nur mit Lizenz
const fp = `${s.enabled}|${s.token}|${s.guildId}|${cachedEnabled}`; // Lizenzwechsel verbindet/trennt ebenfalls
if (fp === lastFingerprint) return;
lastFingerprint = fp;
if (client) { await client.destroy().catch(() => undefined); client = null; }
@ -196,13 +328,14 @@ export async function syncDiscord(log: (m: string) => void): Promise<void> {
}
}
/** Sendet eine Nachricht in den Admin-Kanal. Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string): Promise<'sent' | 'skipped'> {
/** Sendet eine Nachricht in den Admin-Kanal (Ticket-Meldungen in den Support-Kanal #ticket-log, falls angelegt). Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string, target: 'admin' | 'tickets' = 'admin'): Promise<'sent' | 'skipped'> {
if (!client?.isReady()) return 'skipped';
const s = await loadSettings();
if (!s.adminChannelId) return 'skipped';
const ch = await client.channels.fetch(s.adminChannelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Admin-Kanal nicht gefunden oder kein Textkanal');
const channelId = target === 'tickets' && s.ticketLogChannelId ? s.ticketLogChannelId : s.adminChannelId;
if (!channelId) return 'skipped';
const ch = await client.channels.fetch(channelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Meldekanal nicht gefunden oder kein Textkanal');
await ch.send({ content: text, allowedMentions: { parse: [] } });
return 'sent';
}

View file

@ -1,7 +1,7 @@
import http from 'node:http';
import { env } from './env.js';
import { pool } from '@kc/platform/db';
import { discordEnabled, discordReady, syncDiscord, stopDiscord } from './discord.js';
import { discordEnabled, discordReady, reconcileTicketChannels, syncDiscord, stopDiscord } from './discord.js';
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
@ -24,6 +24,8 @@ await recoverStale(log);
void syncDiscord(log);
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
// Discord-Ticketkanäle: offene Tickets bekommen einen Kanal, geschlossene verlieren ihn (nur im Kategorie-Modus)
setInterval(() => void reconcileTicketChannels(log).catch((e) => log(`Ticket-Kanäle: Abgleich fehlgeschlagen: ${(e as Error).message}`)), 60_000);
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
const schedule = () => scheduleDueSyncs((t, p, o) => enqueue(t, p, o)).catch((e) => log(`Planung fehlgeschlagen: ${(e as Error).message}`));
setInterval(schedule, 30_000); void schedule();
@ -42,6 +44,40 @@ const checkBackup = async () => {
};
setInterval(() => void checkBackup(), 3600_000);
// Eigene Lizenz (licensing.flessinglabs.com): "beim Start prüfen, dann periodisch alle 6-24h" laut
// INTEGRATION.md (Rate-Limit 30/min erlaubt deutlich mehr, aber unnötig). checkLicenseNow() ist ein No-Op-
// Fehlschlag, solange kein Programm-Key/Lizenzschlüssel hinterlegt ist (siehe LicenseCheckError) - dann
// einfach weiter versuchen, keine Alarmierung (Betreiber hat die Lizenzierung schlicht noch nicht eingerichtet).
import { checkLicenseNow, LicenseCheckError } from '@kc/platform/license';
const checkLicense = async () => {
try { await checkLicenseNow(); }
catch (e) { if (!(e instanceof LicenseCheckError)) log(`Lizenzprüfung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense();
// Vertrags-Erinnerung: ~30 Tage vor Laufzeitende Mail mit "Behalten"/"Kündigen"-Link (einmal je Vertrag,
// markiert über renewal_reminder_sent_at). Unabhängig von processContractLifecycle oben, die nur zum
// tatsächlichen Laufzeitende selbst verlängert/beendet.
import { sendRenewalReminders } from '@kc/platform/contractRenewal';
const checkRenewals = async () => {
try { const r = await sendRenewalReminders(); if (r.sent || r.skipped) log(`Vertrags-Erinnerungen: ${r.sent} gesendet, ${r.skipped} übersprungen`); }
catch (e) { log(`Vertrags-Erinnerung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void checkRenewals(), 24 * 3600_000); void checkRenewals();
// Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.
const purgeAudit = async () => {
try {
const [row] = (await pool.query('SELECT retention_days FROM audit_settings WHERE id = 1')) as any;
const { purgeAuditRetention } = await import('@kc/platform/audit');
const r = await purgeAuditRetention(row[0]?.retention_days ?? 180);
if (r.purged) log(`Audit-Aufbewahrung: ${r.purged} Einträge gelöscht (älter als ${r.retentionDays} Tage)`);
} catch (e) { log(`Audit-Aufbewahrung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void purgeAudit(), 24 * 3600_000); void purgeAudit();
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));

View file

@ -1,9 +1,15 @@
import { randomUUID } from 'node:crypto';
import { hostname } from 'node:os';
import { pool } from '@kc/platform/db';
import { notifyAdmin, postTicketMessage } from './discord.js';
import { JobFailure } from '@kc/platform/jobs';
import { sendTemplateMail } from '@kc/platform/mail';
import { executeAction, executeChild, provisionOrder, syncInstance, type ChildPayload, type ExecutePayload } from '@kc/connectors';
// Kennung dieses Worker-Prozesses (nur zur Diagnose in locked_by, kein Sicherheitsmerkmal).
const WORKER_ID = `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
const HEARTBEAT_MS = 60_000; // deutlich unter der 5-Minuten-Schwelle in recoverStale
interface JobMeta { id: string; correlationId: string; attempt: number; maxAttempts: number }
type Handler = (payload: any, job: JobMeta) => Promise<string | void>;
/** Job-Handler nach Typ. Neue Module registrieren hier ihre Aufträge. */
@ -17,7 +23,7 @@ const handlers: Record<string, Handler> = {
: p.event === 'ticket.message' ? `🎫 Neue Nachricht zu Ticket ${p.number}`
: p.event === 'invoice.issued' ? `🧾 Rechnung ${p.number} ausgestellt (${(Number(p.gross ?? 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })})` : null; // keine personenbezogenen Daten
if (!text) throw new Error(`Unbekanntes Ereignis: ${p.event}`);
const r = await notifyAdmin(text);
const r = await notifyAdmin(text, String(p.event).startsWith('ticket.') ? 'tickets' : 'admin');
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
},
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {
@ -39,41 +45,53 @@ handlers['connector.child'] = (p: ChildPayload, j) => executeChild(p, j.id, j.co
handlers['connector.execute'] = (p: ExecutePayload, j) => executeAction(p, j.id, j.correlationId);
const backoff = (attempt: number) => Math.min(3600, 30 * 2 ** attempt); // Sekunden, exponentiell
/** Holt einen fälligen Auftrag (SKIP LOCKED) und führt ihn aus. Liefert false, wenn nichts zu tun war. */
/** Holt einen fälligen Auftrag (SKIP LOCKED) und führt ihn aus. Liefert false, wenn nichts zu tun war.
* Bindet Ausführung an einen frischen Lease: solange der Handler läuft, wird locked_at per Heartbeat aktuell
* gehalten (recoverStale reißt einen noch aktiv laufenden Job dadurch nicht mehr an sich), und jedes
* Abschluss-UPDATE ist an genau diesen Lease gebunden – hat recoverStale den Job inzwischen doch freigegeben
* (z. B. weil dieser Prozess abgestürzt und der Heartbeat ausgeblieben ist), verpufft ein verspätetes Ergebnis
* dieses Prozesses wirkungslos, statt den neuen Versuch eines anderen Workers zu überschreiben. */
export async function runOnce(log: (m: string) => void): Promise<boolean> {
const c = await pool.getConnection();
let job: any;
let job: any; let lease: string;
try {
await c.beginTransaction();
const [rows] = await c.query("SELECT * FROM jobs WHERE status IN ('scheduled','retrying') AND run_at <= UTC_TIMESTAMP(3) ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED") as any;
job = rows[0];
if (!job) { await c.rollback(); return false; }
await c.query("UPDATE jobs SET status='running', locked_at=UTC_TIMESTAMP(3), attempts=attempts+1 WHERE id=?", [job.id]);
lease = randomUUID();
await c.query("UPDATE jobs SET status='running', locked_at=UTC_TIMESTAMP(3), lease_id=?, locked_by=?, attempts=attempts+1 WHERE id=?", [lease, WORKER_ID, job.id]);
await c.commit();
} catch (e) { await c.rollback(); throw e; } finally { c.release(); }
const attempt = job.attempts + 1;
const heartbeat = setInterval(() => { pool.query("UPDATE jobs SET locked_at=UTC_TIMESTAMP(3) WHERE id=? AND lease_id=?", [job.id, lease]).catch(() => undefined); }, HEARTBEAT_MS);
try {
const h = handlers[job.type];
if (!h) throw new Error(`Kein Handler für ${job.type}`);
const note = await h(typeof job.payload === 'string' ? JSON.parse(job.payload) : job.payload, { id: job.id, correlationId: job.correlation_id ?? job.id, attempt, maxAttempts: job.max_attempts });
await pool.query("UPDATE jobs SET status='succeeded', locked_at=NULL, last_error=? WHERE id=?", [note ?? null, job.id]);
const [r] = await pool.query("UPDATE jobs SET status='succeeded', locked_at=NULL, lease_id=NULL, locked_by=NULL, last_error=? WHERE id=? AND lease_id=?", [note ?? null, job.id, lease]) as any;
if (!r.affectedRows) log(`Job ${job.id} (${job.type}): Lease inzwischen einem anderen Worker zugewiesen – Ergebnis (erfolgreich) verworfen, um dessen Versuch nicht zu überschreiben`);
} catch (e) {
const msg = String((e as Error).message).slice(0, 500);
const jf = e instanceof JobFailure ? e : null;
const noRetry = jf ? !jf.retry : false;
const dead = noRetry || attempt >= job.max_attempts;
const final = jf?.finalStatus ?? 'needs_review';
await pool.query("UPDATE jobs SET status=?, locked_at=NULL, last_error=?, run_at=DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? SECOND) WHERE id=?", [dead ? final : 'retrying', msg, jf?.delaySec ?? backoff(attempt), job.id]);
log(`Job ${job.id} (${job.type}) Versuch ${attempt} fehlgeschlagen: ${msg}${dead ? ` → ${final}` : ' → wird wiederholt'}`);
}
const [r] = await pool.query("UPDATE jobs SET status=?, locked_at=NULL, lease_id=NULL, locked_by=NULL, last_error=?, run_at=DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? SECOND) WHERE id=? AND lease_id=?", [dead ? final : 'retrying', msg, jf?.delaySec ?? backoff(attempt), job.id, lease]) as any;
if (r.affectedRows) log(`Job ${job.id} (${job.type}) Versuch ${attempt} fehlgeschlagen: ${msg}${dead ? ` → ${final}` : ' → wird wiederholt'}`);
else log(`Job ${job.id} (${job.type}): Lease inzwischen einem anderen Worker zugewiesen – Ergebnis (Fehler) verworfen, um dessen Versuch nicht zu überschreiben`);
} finally { clearInterval(heartbeat); }
return true;
}
/** Nach Absturz hängengebliebene Aufträge wieder freigeben. Nicht-idempotente Typen kämen hier in 'needs_review'. */
/** Nach Absturz hängengebliebene Aufträge wieder freigeben. Nicht-idempotente Typen kämen hier in 'needs_review'.
* locked_at ist der letzte Heartbeat, nicht der Übernahmezeitpunkt – ein noch aktiv laufender Job (Heartbeat alle
* 60 s) wird dadurch nicht angerissen, egal wie lange er tatsächlich läuft. lease_id wird beim Freigeben gelöscht,
* damit ein verspätetes Abschluss-UPDATE des ursprünglichen (mutmaßlich abgestürzten) Workers wirkungslos bleibt. */
export async function recoverStale(log: (m: string) => void): Promise<void> {
// Destruktive Aufträge werden nach einem Absturz NICHT automatisch wiederholt, sondern zur Prüfung markiert.
await pool.query("UPDATE jobs SET status='needs_review', locked_at=NULL, last_error='Unterbrochen während der Ausführung; Ergebnis beim Provider prüfen' WHERE status='running' AND (JSON_VALUE(payload, '$.destructive') IN ('1','true') OR JSON_VALUE(payload, '$.action') = 'terminate') AND locked_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE)");
const [r] = await pool.query("UPDATE jobs SET status='retrying', locked_at=NULL, last_error='Worker-Neustart während der Ausführung' WHERE status='running' AND locked_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE)") as any;
await pool.query("UPDATE jobs SET status='needs_review', locked_at=NULL, lease_id=NULL, locked_by=NULL, last_error='Unterbrochen während der Ausführung; Ergebnis beim Provider prüfen' WHERE status='running' AND (JSON_VALUE(payload, '$.destructive') IN ('1','true') OR JSON_VALUE(payload, '$.action') = 'terminate') AND locked_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE)");
const [r] = await pool.query("UPDATE jobs SET status='retrying', locked_at=NULL, lease_id=NULL, locked_by=NULL, last_error='Worker-Neustart oder ausgebliebener Heartbeat während der Ausführung' WHERE status='running' AND locked_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE)") as any;
if (r.affectedRows) log(`${r.affectedRows} hängende Aufträge wieder eingeplant`);
}

View file

@ -4,6 +4,7 @@ import { simpleParser } from 'mailparser';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
import { hasFeature } from '@kc/platform/license';
/** Sehr einfache HTML->Text-Reduktion für Mails ohne Text-Teil. Nur zur Anzeige, nie als HTML gerendert. */
function htmlToText(html: string): string {
@ -27,6 +28,7 @@ export async function checkMailbox(log: (m: string) => void): Promise<void> {
const [rows] = await pool.query('SELECT * FROM imap_settings WHERE id = 1') as any;
const s = rows[0];
if (!s?.enabled || !s?.host || !s?.username || !s?.secrets_enc) return;
if (!(await hasFeature('imap'))) return; // E-Mail-Posteingang nur mit Lizenz
const password = JSON.parse(decrypt(s.secrets_enc)).password as string;
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
try {

View file

@ -1,50 +0,0 @@
# Lizenzsystem-Erweiterung: Edition (Schlüssel-Präfix) und festes Ablaufdatum
**Zweck:** Das Kundencenter soll Familytool-Stufen verkaufen (Premium, Unlimited, Lifetime, Trial). Das Familytool erkennt die Stufe am **Präfix des Lizenzschlüssels** (`PREMIUM-…`, `TRIAL-…`, `LIFETIME…`, sonst Unlimited). Das Lizenzsystem konnte bisher nur Schlüssel ohne Präfix erzeugen, jede Lizenz wäre also automatisch Unlimited gewesen.
**Wo einspielen:** auf der Instanz, gegen die das Kundencenter und das Familytool laufen, also **licensing.flessinglabs.com** (nicht auf dem lokalen Dienst dieses Servers). Ohne den Patch verweigert das Kundencenter Bestellungen mit Edition (das Produkt bleibt Entwurf, eine Bestellung würde nie eine falsche Lizenz anlegen).
Getestet an einer Kopie des Codes mit eigener Testdatenbank auf einem separaten Port: Präfix für alle drei Editionen, ungültiges Präfix → 422, festes Ablaufdatum, unveränderte Anlage ohne Präfix, `validate` mit Präfix-Schlüssel, Erkennung über `GET /`.
## Ablauf (ca. 10 Minuten, kurze Unterbrechung nur beim Neustart)
1. **Sichern** (DB und Code des Lizenzsystems auf dem Zielserver):
```bash
mysqldump --single-transaction <DBNAME> > licensing-vor-erweiterung.sql
cp -a <pfad>/backend/app app-vor-erweiterung
```
2. **Vorab prüfen: startet der Dienst nach einem Neustart korrekt?** (Wir hatten am 26.09. auf dem lokalen Dienst einen Ausfall, weil das venv neuer war als der laufende Prozess. Siehe LICENSING-11.)
```bash
<pfad>/backend/venv/bin/pip list | grep -iE "bcrypt|passlib|slowapi|starlette" # bcrypt muss < 5 sein (z. B. 4.0.1)
grep -n "def validate_license" -A3 <pfad>/backend/app/routers/licenses.py # Parameter muss "request: Request" heißen
```
Wenn `bcrypt` ≥ 5 ist oder die Schema-Parameter noch `request` heißen: **erst das beheben**, sonst fallen Login und `validate` beim Neustart aus.
3. **Datenbank erweitern** (Schlüssel werden mit Präfix länger als 36 Zeichen):
```sql
ALTER TABLE licenses MODIFY license_key VARCHAR(64) NOT NULL;
```
4. **Code anpassen** (erst Trockenlauf, dann anwenden):
```bash
python3 apply_erweiterung.py <pfad>/backend # zeigt nur, was geändert würde
python3 apply_erweiterung.py <pfad>/backend --apply # legt Sicherungen *.bak-<zeit> an
```
Weicht der Code ab, bricht das Skript ohne Änderung ab und nennt die Stelle.
5. **Dienst neu starten** (z. B. `systemctl restart licensing-backend`) und prüfen:
```bash
curl -s https://licensing.flessinglabs.com/api/ # muss "features":["key_prefix","explicit_expiry"] enthalten
```
Danach kurz testen: Login im Admin-Panel, eine Lizenz prüfen (`validate`) und im Familytool die Lizenzprüfung.
6. **Im Kundencenter**: Verbindungen → „Jetzt abgleichen“. Unter „Fähigkeiten“ erscheinen `license.key_prefix` und `license.expiry`. Danach lassen sich die Familytool-Produkte aktivieren.
## Zurückrollen
Dienst stoppen, `*.bak-<zeit>` zurückkopieren, Dienst starten. Die Spalte darf auf 64 Zeichen bleiben. Bereits mit Präfix angelegte Lizenzen bleiben gültig.
## API-Änderungen im Detail
- `POST /licenses/` Body: neues optionales Feld `key_prefix` (`PREMIUM` | `TRIAL` | `LIFETIME`). Antwort enthält den Schlüssel `<PRAEFIX>-<uuid>`.
- `POST /licenses/`: ein angegebenes `expires_at` (Ortszeit wie bisher) wird verwendet; ohne Angabe gilt die bisherige Berechnung aus `duration_type`.
- `GET /`: Feld `features` (Liste).
- Nicht geändert: `validate`, `register`, Shop, Login, alle bestehenden Felder.
## Bekannte Grenzen
- Ein Upgrade von Premium auf Unlimited bedeutet einen **neuen Schlüssel** (das Präfix steckt im Schlüssel). Das Kundencenter legt dann eine neue Lizenz an; die alte wird gesperrt. Eine spätere Lösung mit einem Feld `edition` im Lizenzsystem (statt Präfix) ist möglich.
- Das Ablaufdatum wird (wie im Lizenzsystem üblich) als Ortszeit gespeichert.

View file

@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""
Erweiterung des Lizenzsystems um "Edition" (Schlüssel-Präfix) und festes Ablaufdatum – für das Kundencenter.
Was der Patch tut (alles abwärtskompatibel, bestehende Clients ändern sich nicht):
1. POST /licenses/ akzeptiert optional key_prefix: "PREMIUM" | "TRIAL" | "LIFETIME"
-> Schlüssel lautet dann "<PRAEFIX>-<uuid>" (das Familytool erkennt daran die Stufe; ohne Präfix = Unlimited).
2. POST /licenses/ beachtet ein ausdrücklich angegebenes expires_at (bisher wurde es ignoriert).
3. GET / meldet "features": ["key_prefix", "explicit_expiry"] (daran erkennt das Kundencenter die Erweiterung).
4. models.License.license_key wird von 36 auf 64 Zeichen erweitert (DB-Änderung: siehe README, VORHER ausführen).
Nutzung (Standard = nur prüfen, nichts ändern):
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend # Trockenlauf
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend --apply # anwenden (mit Sicherungskopien *.bak-<zeit>)
Das Skript ist idempotent: bereits vorhandene Änderungen werden erkannt und übersprungen.
"""
import re, sys, time, py_compile, pathlib
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith('--')]
apply = '--apply' in sys.argv
if len(args) != 1:
print(__doc__); return 2
root = pathlib.Path(args[0]) / 'app'
files = {n: root / p for n, p in {'models': 'models.py', 'schemas': 'schemas.py', 'licenses': 'routers/licenses.py', 'main': 'main.py'}.items()}
for n, f in files.items():
if not f.exists():
print(f'FEHLER: {f} nicht gefunden – falscher Pfad? (erwartet: .../licensing-system/backend)'); return 1
src = {n: f.read_text() for n, f in files.items()}
new = dict(src); report = []
def step(name, key, old, repl, marker):
if marker in new[key]:
report.append(f' = {name}: bereits vorhanden'); return True
if old not in new[key]:
report.append(f' ! {name}: erwartete Stelle NICHT gefunden – bitte manuell prüfen (siehe README)'); return False
new[key] = new[key].replace(old, repl, 1); report.append(f' + {name}'); return True
ok = True
ok &= step('models: license_key 64 Zeichen', 'models',
'license_key = Column(String(36), unique=True, index=True, default=lambda: str(uuid.uuid4()))',
'license_key = Column(String(64), unique=True, index=True, default=lambda: str(uuid.uuid4()))', 'String(64), unique=True')
ok &= step('schemas: key_prefix in LicenseCreate', 'schemas',
'class LicenseCreate(LicenseBase):\n pass',
'class LicenseCreate(LicenseBase):\n # Optionales Schlüssel-Präfix (Edition): Schlüssel lautet "<PRAEFIX>-<uuid>". Ohne Präfix gilt die Lizenz im Familytool als UNLIMITED.\n key_prefix: Optional[Literal["PREMIUM", "TRIAL", "LIFETIME"]] = None',
'key_prefix: Optional[Literal')
if 'Literal' not in re.split(r'\nclass ', new['schemas'], maxsplit=1)[0]:
m = re.search(r'^from typing import (.+)$', new['schemas'], re.M)
new['schemas'] = new['schemas'].replace(m.group(0), f'from typing import Literal, {m.group(1)}', 1) if m else 'from typing import Literal\n' + new['schemas']
report.append(' + schemas: Literal importiert')
ok &= step('licenses: festes Ablaufdatum hat Vorrang', 'licenses',
' # Calculate expires_at based on duration_type\n expires_at = None\n if license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
' # Ein ausdrücklich angegebenes Ablaufdatum hat Vorrang; sonst wird es aus duration_type berechnet\n expires_at = license.expires_at\n if expires_at is None and license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
'expires_at = license.expires_at')
ok &= step('licenses: Schlüssel mit Präfix', 'licenses',
' db_license = models.License(\n program_id=license.program_id,',
' extra = {"license_key": f"{license.key_prefix}-{uuid.uuid4()}"} if license.key_prefix else {}\n db_license = models.License(\n **extra,\n program_id=license.program_id,',
'f"{license.key_prefix}-')
if not re.search(r'^import uuid$', new['licenses'], re.M):
new['licenses'] = re.sub(r'^import datetime$', 'import datetime\nimport uuid', new['licenses'], count=1, flags=re.M); report.append(' + licenses: uuid importiert')
ok &= step('main: features in GET /', 'main',
'return {"message": "Licensing System Backend is running!"}',
'return {"message": "Licensing System Backend is running!", "features": ["key_prefix", "explicit_expiry"]}', '"features"')
print('\n'.join(report))
if not ok:
print('\nABBRUCH: Nicht alle Stellen passen (Code weicht ab). Es wurde NICHTS geändert.'); return 1
changed = [n for n in files if new[n] != src[n]]
if not changed:
print('\nNichts zu tun – die Erweiterung ist bereits vollständig eingespielt.'); return 0
if not apply:
print(f'\nTROCKENLAUF: würde {", ".join(files[n].name for n in changed)} ändern. Mit --apply ausführen.'); return 0
ts = time.strftime('%Y%m%d-%H%M%S')
for n in changed:
files[n].with_name(files[n].name + f'.bak-{ts}').write_text(src[n])
files[n].write_text(new[n])
py_compile.compile(str(files[n]), doraise=True)
print(f'\nANGEWENDET: {", ".join(files[n].name for n in changed)} (Sicherungen: *.bak-{ts}). Jetzt Dienst neu starten (siehe README).')
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -1,53 +0,0 @@
# Nacht-Agent
Arbeitet einmal pro Nacht (01:00 Uhr, `kc-night-agent.timer`) offene Plane-Tickets mit dem Label
**„agent“** ab, in den Status Backlog oder Todo. Ergebnis ist **immer** ein Branch auf der Forge
(`https://forge.flessinghome.de/flessinglabs/kundencenter`), bei Erfolg zusätzlich ein Pull
Request und Ticket-Status „In Review“. **Nichts wird automatisch gemergt oder live geschaltet** –
das bleibt bei der morgendlichen Durchsicht durch den Assistenten bzw. den Nutzer.
## Absicherung
Der Agent läuft als Betriebssystembenutzer `kundencenter` (systemd-Dienst `kc-night-agent.service`,
zusätzlich `ProtectSystem=strict`/`ProtectHome=true`, nur `/var/lib/kundencenter/night-agent`
beschreibbar). Dieser Benutzer:
- hat kein `sudo`, kann also kein `systemctl restart` und keine Dienste anfassen,
- kann `/etc/kundencenter/*` nicht lesen (root:root, chmod 600) – keine Geheimnisse erreichbar,
- arbeitet in einer **eigenen frischen Arbeitskopie** (`/var/lib/kundencenter/night-agent/work`),
niemals in `/srv/kundencenter` selbst.
Zusätzlich bekommt jeder Lauf feste Leitplanken als System-Prompt
(`ops/night-agent-guardrails.md`): ein Ticket = ein Branch, Tests müssen grün sein, keine
Vermutungen bei fehlenden Zugangsdaten, kein Aufräumen außerhalb des Tickets.
## Ablauf je Ticket
1. Plane-Status → „In Progress“, Kommentar mit Branch-Namen.
2. `claude -p` mit Ticket-Text + Leitplanken, Budget-Deckel (`--max-budget-usd`, Standard 3 $),
Zeit-Deckel (Standard 45 Min./Ticket, insgesamt max. 150 Min./Nacht).
3. Unabhängig vom Agenten prüft das Skript selbst: `pnpm install && pnpm build && pnpm typecheck
&& pnpm test` (Tests laufen automatisch gegen eine frische `kundencenter_test`, nie gegen die
produktive Datenbank).
4. Grün + Agent meldet `AGENT_STATUS: ok` → Branch pushen, Pull Request eröffnen, Plane-Status
„In Review“, Kommentar mit PR-Link.
5. Rot, oder Agent meldet `AGENT_STATUS: blocked`, oder keine Änderungen → Branch wird trotzdem
gepusht (nichts geht verloren), aber **kein** Pull Request; Kommentar in Plane beschreibt das
Problem. Status bleibt unverändert (kein automatisches „Wartet auf Input“, das entscheidet der
Mensch nach Lektüre des Kommentars).
## Einrichtung
- `/etc/kundencenter/night-agent.env` (chmod 600, root): `ANTHROPIC_API_KEY` (eigener, vom
interaktiven Zugang getrennter Schlüssel – siehe unten) sowie optionale Stellschrauben
(`NIGHT_AGENT_MAX_TICKETS`, `NIGHT_AGENT_BUDGET_USD`, `NIGHT_AGENT_DEADLINE_MIN`,
`NIGHT_AGENT_TICKET_TIMEOUT_MIN`).
- `/etc/kundencenter/forge.env`: `FORGE_URL`, `FORGE_REPO` (`flessinglabs/kundencenter`),
`FORGE_TOKEN`.
- Plane-Label „agent“ muss auf ein Ticket gesetzt sein, damit der Agent es aufgreifen darf.
- Aktivieren: `systemctl enable --now kc-night-agent.timer`. Einmalig von Hand testen:
`systemctl start kc-night-agent.service` (Logs: `journalctl -u kc-night-agent` sowie
`/var/lib/kundencenter/night-agent/logs/`).
## Bekannte Grenzen
- `kundencenter_test` wird bei jedem `pnpm test` neu angelegt (siehe `apps/api/test/global-setup.ts`)
– Agent-Lauf und ein manueller Testlauf am Tag sollten sich nicht überschneiden.
- Der Agent öffnet nie mehr als `NIGHT_AGENT_MAX_TICKETS` Pull Requests pro Nacht.
- Kein Zugriff auf Anbieter-Testzugänge (z. B. KeyHelp, KCS) – bei Tickets, die das brauchen,
meldet der Agent `blocked`.

View file

@ -34,6 +34,6 @@ Jede Produktversion hat eine Preisbasis. **Brutto**: der Betrag ist der Endkunde
Pakete beschreiben mehrere Produkte einer Software (Editionen, Preise, Laufzeiten, Provisionierung). Auf der Übernahmeseite wählt man ein Programm der Verbindung und importiert Paketprodukte als **Entwürfe**. Vorhanden: `familytool.json` (Premium/Unlimited monatlich+jährlich, Lifetime, Trial 14 Tage; Preise brutto laut LICENSE_TIERS.md).
## Edition (Familytool) und Laufzeitpflege
- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl. Patch: `docs/lizenzsystem-erweiterung/`.
- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl.
- Nach der Bereitstellung wird das Ablaufdatum der Lizenz an das Vertragsende angeglichen; bei jeder **Vertragsverlängerung** wird die Lizenz mit verlängert (Auftrag `extend`, idempotent). Bis zur Rechnungs-/Zahlungsanbindung geschieht das **ohne Zahlungsprüfung**.
- Rollierende Monatsverträge (Mindestlaufzeit 0, automatische Verlängerung) erhalten einen monatlichen Verlängerungstakt. Upgrade Premium → Unlimited bedeutet einen neuen Schlüssel.

View file

@ -0,0 +1,6 @@
-- Behebt zwei vom Nacht-Agent gefundene und verifizierte Lücken in der Audit-Hash-Kette:
-- 1) Das Ergebnis von GET_LOCK() wurde nie geprüft (bei Timeout/Fehler lief die Kette ungesperrt weiter).
-- 2) connector/ip wurden zwar gespeichert, aber nie mitgehasht (im Nachhinein unbemerkt änderbar).
-- Neue Einträge (hash_version = 2) hashen connector/ip mit; bestehende Einträge (Version 1) bleiben mit
-- ihrer ursprünglichen Formel gültig, damit die Kette rückwärtskompatibel bleibt.
ALTER TABLE audit_events ADD COLUMN hash_version TINYINT UNSIGNED NOT NULL DEFAULT 1;

View file

@ -0,0 +1,13 @@
-- Firmenstammdaten für den DATEV-Buchungsstapel-Export (EXTF, Format 700/21). Alle Felder optional/NULL,
-- solange niemand den DATEV-Export nutzt; der CSV-/ZIP-Export (Migration keine nötig, reine Anwendungslogik)
-- funktioniert unabhängig davon bereits.
ALTER TABLE company_settings
ADD COLUMN datev_berater_nr INT NULL, -- Beraternummer (>= 1001), vom Steuerberater
ADD COLUMN datev_mandant_nr INT NULL, -- Mandantennummer, vom Steuerberater
ADD COLUMN datev_skr CHAR(2) NULL, -- Kontenrahmen, z. B. '03' oder '04'
ADD COLUMN datev_sachkontenlaenge TINYINT NOT NULL DEFAULT 4, -- Länge der Sachkontonummern (4-8)
ADD COLUMN datev_fiscal_year_start CHAR(5) NOT NULL DEFAULT '01-01', -- Wirtschaftsjahresbeginn als MM-DD
ADD COLUMN datev_erloeskonto_19 INT NULL, -- Erlöskonto für 19 % USt (z. B. SKR04 4400)
ADD COLUMN datev_erloeskonto_7 INT NULL, -- Erlöskonto für 7 % USt (z. B. SKR04 4300)
ADD COLUMN datev_erloeskonto_0 INT NULL, -- Erlöskonto für 0 % / steuerfreie Umsätze
ADD COLUMN datev_diktatkuerzel VARCHAR(2) NULL; -- optionales Bearbeiterkürzel (Kopfzeile)

View file

@ -0,0 +1,6 @@
-- Nightbot-Befund #99/#16: recoverStale gab "running"-Jobs nach starren 5 Minuten wieder frei, ohne zu prüfen,
-- ob der ursprüngliche Worker noch aktiv daran arbeitet. Ein legitim länger laufender Job konnte dadurch von
-- einem zweiten Worker parallel erneut gestartet werden (Doppelausführung).
ALTER TABLE jobs
ADD COLUMN lease_id CHAR(36) NULL, -- pro Übernahme neu vergeben; nur der aktuelle Lease darf das Ergebnis schreiben
ADD COLUMN locked_by VARCHAR(100) NULL; -- Worker-Kennung (Host:PID:Zufall) für Diagnose, kein Sicherheitsmerkmal

View file

@ -0,0 +1,10 @@
-- Nightbot-Befund #34 (Teil "eigene DB-Rolle für Audit ohne UPDATE/DELETE"): eine echte separate DB-Rolle
-- ist mit dem bestehenden Muster (audit() schreibt oft in derselben Transaktion wie die Fachaktion, die sie
-- protokolliert) nicht sauber vereinbar, ohne diese Atomarität zu verlieren. Stattdessen: Trigger, die UPDATE/
-- DELETE auf audit_events unabhängig vom verbindenden DB-Nutzer grundsätzlich verweigern (append-only auf
-- DB-Ebene, nicht nur per Konvention im Anwendungscode). INSERT/SELECT bleiben uneingeschränkt möglich.
CREATE TRIGGER audit_events_no_update BEFORE UPDATE ON audit_events
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_events ist unveraenderlich (append-only) - UPDATE nicht erlaubt';
CREATE TRIGGER audit_events_no_delete BEFORE DELETE ON audit_events
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_events ist unveraenderlich (append-only) - DELETE nicht erlaubt';

View file

@ -0,0 +1,23 @@
-- Zweiter Teil von #34: Aufbewahrungsfrist als eigene, änderbare Policy (kein fester Gesetzeswert - die DSGVO
-- selbst schreibt für sowas keine feste Zahl vor, sondern nur den Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1
-- lit. e). Voreinstellung 180 Tage als verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten;
-- vom Nutzer je nach eigener Lösch-/Aufbewahrungsrichtlinie anzupassen.
CREATE TABLE audit_settings (
id TINYINT PRIMARY KEY DEFAULT 1,
retention_days INT NOT NULL DEFAULT 180,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
INSERT INTO audit_settings (id) VALUES (1);
-- Löschen einzelner Zeilen bricht die Hash-Kette (jede Zeile hasht die vorherige mit); ein Checkpoint macht das
-- rückwirkende Löschen alter Zeilen trotzdem nachvollziehbar überprüfbar: verifyAuditChain beginnt danach nicht
-- mehr bei der Genesis-Null, sondern beim Hash der zuletzt gelöschten Zeile, sofern die neue erste Zeile genau
-- darauf verweist (sonst würde eine echte Manipulation als "nur aufgeräumt" durchgehen).
CREATE TABLE audit_retention_checkpoints (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
purged_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
purged_count INT NOT NULL,
purged_through_id BIGINT NOT NULL,
checkpoint_hash CHAR(64) NOT NULL,
retention_days INT NOT NULL
);

View file

@ -0,0 +1,37 @@
-- Kundencenter-Lizenzierung (Meilenstein #113-#117): eigenes Lizenzsystem-Konto (licensing.flessinglabs.com),
-- nicht zu verwechseln mit dem bestehenden "licensing"-Connector (der verwaltet KUNDEN-Lizenzen, die das
-- Kundencenter weiterverkauft). Dies hier ist die Lizenz der Kundencenter-Installation selbst.
CREATE TABLE license_state (
id TINYINT PRIMARY KEY DEFAULT 1,
license_key_enc TEXT NULL, -- verschlüsselt (wie andere Secrets), vom Betreiber selbst eingetragen
instance_id CHAR(36) NOT NULL, -- einmal erzeugt, bleibt stabil (zählt sonst als neues Gerät)
-- zuletzt erfolgreich geprüfter (und signaturgeprüfter) Entitlement-Snapshot
valid BOOLEAN NULL,
message VARCHAR(300) NULL,
plan VARCHAR(100) NULL,
modules_json JSON NULL,
expires_at DATETIME NULL,
user_limit INT NULL,
customer_limit INT NULL,
entitlement_version INT NULL,
is_trial BOOLEAN NOT NULL DEFAULT FALSE,
addons_json JSON NULL,
server_time DATETIME NULL,
checked_at DATETIME(3) NULL, -- Zeitpunkt der letzten ERFOLGREICHEN Online-Prüfung (Basis fürs Offline-Fenster)
last_attempt_at DATETIME(3) NULL,
last_error VARCHAR(500) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
INSERT INTO license_state (id, instance_id) VALUES (1, UUID());
-- Öffentliche Signaturschlüssel (GET /license/signing-keys), lokal zwischengespeichert statt bei jeder Prüfung
-- abgerufen - sonst könnte ein kompromittierter/vorgetäuschter Server zu einer gefälschten Antwort auch gleich
-- einen passenden Schlüssel mitliefern. Werden nur ergänzt, nie anhand der Liste selbst gelöscht.
CREATE TABLE license_signing_keys (
kid VARCHAR(100) PRIMARY KEY,
alg VARCHAR(20) NOT NULL,
n_hex TEXT NOT NULL,
e_hex VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
fetched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);

View file

@ -0,0 +1,7 @@
-- Support-/Admin-Impersonation ("als Kunde ansehen"): zeitlich begrenzt, mit Grund, auf der bestehenden
-- Sitzung des Staff-Nutzers (kein zweiter Login nötig, "zurück" ist einfach die Sitzung zurücksetzen).
ALTER TABLE sessions
ADD COLUMN impersonating_org_id CHAR(36) NULL,
ADD COLUMN impersonation_reason VARCHAR(300) NULL,
ADD COLUMN impersonation_started_at DATETIME(3) NULL,
ADD COLUMN impersonation_expires_at DATETIME(3) NULL;

View file

@ -0,0 +1,27 @@
-- Erinnerung vor Vertragsende: ~30 Tage vorher eine Mail mit "Behalten"/"Kündigen"-Entscheidung, egal ob der
-- Vertrag automatisch verlängert (renewal='auto') oder ausläuft (renewal='none'). "Behalten" verlängert
-- bewusst um eine Laufzeit (auch bei renewal='none', ohne die Einstellung selbst zu ändern - nächstes Mal wird
-- wieder gefragt). "Kündigen" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung UND legt ein
-- Ticket an, damit Personal die externe Abmeldung (z. B. beim Domain-Registrar) tatsächlich vornehmen kann.
ALTER TABLE contracts ADD COLUMN renewal_reminder_sent_at DATETIME(3) NULL;
-- Eigene Token-Tabelle statt user_tokens: eine Entscheidung bezieht sich auf einen VERTRAG, nicht auf den
-- einloggenden Nutzer selbst (ein Kunde kann mehrere Verträge haben) - user_tokens hat dafür keine Spalte.
-- Gleiche Hash-/Ablauf-/Einmal-Mechanik wie user_tokens.
CREATE TABLE contract_renewal_tokens (
id CHAR(36) PRIMARY KEY,
contract_id CHAR(36) NOT NULL REFERENCES contracts(id),
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME(3) NOT NULL,
used_at DATETIME(3) NULL,
used_action ENUM('keep','cancel') NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX (contract_id)
);
INSERT INTO mail_templates (`key`, name, trigger_text, category, variables_json, subject, body) VALUES
('contract_renewal_reminder', 'Erinnerung vor Vertragsende', 'Automatisch, ca. 30 Tage vor Laufzeitende eines Vertrags (einmalig je Vertrag).',
'kundencenter',
JSON_ARRAY('name', 'contractNumber', 'productName', 'termEnd', 'keepLink', 'cancelLink'),
'Ihr Vertrag {{contractNumber}} läuft bald ab',
'Hallo {{name}},\n\nIhr Vertrag {{contractNumber}} ({{productName}}) läuft am {{termEnd}} aus. Über die Schaltflächen unten können Sie ihn behalten oder kündigen.\n\nBei „Behalten" verlängern wir den Vertrag automatisch um eine weitere Laufzeit. Bei „Kündigen" kümmern wir uns um die Abmeldung. Ohne Rückmeldung läuft der Vertrag nach unseren üblichen Bedingungen weiter bzw. aus.\n\n{{keepLink}}\n{{cancelLink}}\n\nBei Fragen antworten Sie gerne auf diese E-Mail.\n');

View file

@ -0,0 +1,7 @@
-- Discord: je offenem Ticket ein eigener Kanal in einer Kategorie (statt privater Threads), sichtbar nur für die
-- Support-Rollen und die verknüpften Mitglieder des Kunden. Ticket-Meldungen gehen in einen eigenen Support-Kanal.
-- ticket_discord_threads.thread_id enthält im Kategorie-Modus die Kanal-ID.
ALTER TABLE discord_settings
ADD COLUMN ticket_category_id VARCHAR(32) NULL,
ADD COLUMN ticket_log_channel_id VARCHAR(32) NULL,
ADD COLUMN support_role_ids VARCHAR(500) NULL;

View file

@ -1,49 +0,0 @@
# Nacht-Agent: verbindliche Regeln
Du bist der Nacht-Agent des Kundencenters. Du bearbeitest **genau ein** Plane-Ticket in diesem
Lauf, in einer frischen Kopie des Repos (nicht das laufende System). Halte dich strikt an diese
Regeln, auch wenn eine Aufgabe etwas anderes nahelegt:
## Harte Grenzen
- Arbeite ausschließlich innerhalb dieses Arbeitsverzeichnisses (Git-Arbeitskopie). Du hast
ohnehin keine Rechte außerhalb (kein sudo, `/etc/kundencenter` ist für dich nicht lesbar,
`systemctl` funktioniert für dich nicht) – versuche es erst gar nicht.
- Kein `systemctl`, kein `chown`, kein Neustart von Diensten, kein Ausführen von Migrationen
gegen die Produktionsdatenbank (`kundencenter`). Tests laufen ausschließlich gegen
`kundencenter_test` (das passiert automatisch über `vitest.config.ts`).
- Kein Zugriff auf `/etc/kundencenter/*`, keine Geheimnisse in Commits, Kommentaren oder Logs.
- Kein `git push --force`, kein Schreiben auf `main`, kein Löschen fremder Branches, kein Merge
von Pull Requests. Du legst nur deinen eigenen Branch `agent/<ticket>-<kurzname>` an.
- Wenn eine Aufgabe eine echte Anbieter-Anbindung (Zugangsdaten, Testzugang, API-Dokumentation)
braucht, die du nicht hast: nicht raten, keine Platzhalter-Zugangsdaten erfinden. Dokumentiere
das als Blocker (siehe unten) und höre auf.
## Vorgehen
1. Lies das Ticket (Titel, Beschreibung, Kommentare) und die relevante Dokumentation (`README.md`,
`docs/*.md`) sowie den bestehenden Code für ähnliche Module, bevor du etwas änderst.
2. Halte dich an die bestehenden Konventionen: deutschsprachige Texte für Benutzer, Geldbeträge in
Cent, Migrationen fortlaufend nummeriert unter `migrations/`, neue API-Module als `KcModule`
unter `apps/api/src/modules/<name>` mit Rechteprüfung (`requirePermission`) und Audit-Eintrag
(`audit(...)`) bei schreibenden Aktionen, Tests für neue Logik und neue Endpunkte.
3. Setze **nur** um, was für dieses eine Ticket nötig ist. Kein Aufräumen an anderer Stelle, keine
Umbenennungen "nebenbei".
4. Führe `pnpm -r test` aus. Bei Web-Änderungen zusätzlich `pnpm --filter @kc/web typecheck`.
Beides muss grün sein, bevor du fertig bist.
5. Wenn du fertig bist (oder nicht weiterkommst), gib **als letzten Absatz deiner Antwort genau
einen** dieser Blöcke aus, sonst nichts danach:
```
AGENT_STATUS: ok
ZUSAMMENFASSUNG: <2-4 Sätze auf Deutsch, was geändert wurde und was noch zu prüfen ist>
```
oder, wenn du das Ticket nicht abschließen konntest:
```
AGENT_STATUS: blocked
GRUND: <was fehlt oder unklar ist – z. B. fehlender Testzugang, fehlende Entscheidung>
```
Committe deine Änderungen (auch bei `blocked`, wenn du Teilarbeit geleistet hast) mit einer
kurzen, deutschen Commit-Message. Führe keine Aktion aus, die über "Code ändern, testen,
committen" hinausgeht.

View file

@ -1,174 +0,0 @@
#!/usr/bin/env node
// Nacht-Agent: arbeitet nachts offene Plane-Tickets mit dem Label "agent" ab.
// Läuft als Benutzer "kundencenter" (kein sudo, kein Zugriff auf /etc/kundencenter, kein systemctl —
// das ist die eigentliche Absicherung, nicht nur diese Regeln hier).
// Ergebnis: ein Branch + Pull Request je erledigtem Ticket auf der Forge. Nichts wird live geschaltet,
// nichts wird automatisch gemerged. Siehe docs/night-agent.md.
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
const env = process.env;
const need = (n) => { const v = env[n]; if (!v) { console.error(`Fehlt: ${n} (siehe /etc/kundencenter/night-agent.env, forge.env, plane.env)`); process.exit(2); } return v; };
const FORGE_URL = need('FORGE_URL'), FORGE_REPO = need('FORGE_REPO'), FORGE_TOKEN = need('FORGE_TOKEN');
const PLANE_BASE_URL = need('PLANE_BASE_URL'), PLANE_WORKSPACE = need('PLANE_WORKSPACE'), PLANE_PROJECT_ID = need('PLANE_PROJECT_ID'), PLANE_API_KEY = need('PLANE_API_KEY');
need('ANTHROPIC_API_KEY');
const WORK_DIR = env.NIGHT_AGENT_WORK_DIR || '/var/lib/kundencenter/night-agent/work';
const LOG_DIR = env.NIGHT_AGENT_LOG_DIR || '/var/lib/kundencenter/night-agent/logs';
const MAX_TICKETS = Number(env.NIGHT_AGENT_MAX_TICKETS || 4);
const BUDGET_PER_TICKET = env.NIGHT_AGENT_BUDGET_USD || '3';
const DEADLINE_MS = Date.now() + Number(env.NIGHT_AGENT_DEADLINE_MIN || 150) * 60_000;
const PER_TICKET_TIMEOUT_MS = Number(env.NIGHT_AGENT_TICKET_TIMEOUT_MIN || 45) * 60_000;
const LABEL_NAME = env.NIGHT_AGENT_LABEL || 'agent';
const AGENT_ORIGIN = FORGE_URL.replace('https://', `https://night-agent:${FORGE_TOKEN}@`);
mkdirSync(LOG_DIR, { recursive: true });
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const logFile = join(LOG_DIR, `${stamp}.log`);
const log = (...a) => { const line = `[${new Date().toISOString()}] ${a.join(' ')}`; console.log(line); appendFileSync(logFile, line + '\n'); };
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { cwd: WORK_DIR, encoding: 'utf8', ...opts });
if (r.status !== 0 && !opts.allowFail) throw new Error(`${cmd} ${args.join(' ')} fehlgeschlagen (${r.status}):\n${r.stderr || r.stdout}`);
return r;
}
// ---- Plane -----------------------------------------------------------------
const planeBase = `${PLANE_BASE_URL}/api/v1/workspaces/${PLANE_WORKSPACE}/projects/${PLANE_PROJECT_ID}`;
async function plane(path, opts = {}) {
const r = await fetch(`${planeBase}${path}`, { ...opts, headers: { 'x-api-key': PLANE_API_KEY, 'content-type': 'application/json', ...(opts.headers || {}) } });
if (!r.ok) throw new Error(`Plane ${opts.method || 'GET'} ${path}: HTTP ${r.status} ${await r.text().catch(() => '')}`);
return r.status === 204 ? null : r.json();
}
async function planeStates() { const r = await plane('/states/'); return Object.fromEntries((r.results ?? r).map((s) => [s.name, s.id])); }
async function planeLabelId(name) { const r = await plane('/labels/'); const l = (r.results ?? r).find((x) => x.name === name); return l?.id ?? null; }
async function planeComment(issueId, text) { return plane(`/issues/${issueId}/comments/`, { method: 'POST', body: JSON.stringify({ comment_html: `<p>${text.replace(/\n/g, '<br/>')}</p>` }) }); }
async function planeSetState(issueId, stateId) { return plane(`/issues/${issueId}/`, { method: 'PATCH', body: JSON.stringify({ state: stateId }) }); }
// ---- Forgejo -----------------------------------------------------------------
async function forgejo(path, opts = {}) {
const r = await fetch(`${FORGE_URL}/api/v1${path}`, { ...opts, headers: { Authorization: `token ${FORGE_TOKEN}`, 'content-type': 'application/json', ...(opts.headers || {}) } });
if (!r.ok) throw new Error(`Forge ${opts.method || 'GET'} ${path}: HTTP ${r.status} ${await r.text().catch(() => '')}`);
return r.status === 204 ? null : r.json();
}
async function openPr(branch, title, body) {
return forgejo(`/repos/${FORGE_REPO}/pulls`, { method: 'POST', body: JSON.stringify({ head: branch, base: 'main', title, body }) });
}
// ---- Arbeitskopie -------------------------------------------------------------
function prepareWorkdir() {
if (!existsSync(join(WORK_DIR, '.git'))) {
log('Klone Repo nach', WORK_DIR);
mkdirSync(WORK_DIR, { recursive: true });
run('git', ['clone', AGENT_ORIGIN, '.']);
} else {
run('git', ['fetch', 'origin', 'main']);
run('git', ['checkout', 'main']);
run('git', ['reset', '--hard', 'origin/main']);
run('git', ['clean', '-fd']); // node_modules/dist bleiben (.gitignore) → kein Neuinstallieren jede Nacht
}
}
const slug = (s) => s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40);
function extractStatus(text) {
const m = /AGENT_STATUS:\s*(ok|blocked)\s*\n(?:ZUSAMMENFASSUNG|GRUND):\s*([\s\S]*?)\s*$/i.exec(text || '');
if (!m) return { status: 'unknown', note: '(Der Agent hat keinen Abschlussblock ausgegeben.)' };
return { status: m[1].toLowerCase(), note: m[2].trim() };
}
async function runClaudeOnTicket(issue) {
const guardrails = readFileSync(join(new URL('.', import.meta.url).pathname, 'night-agent-guardrails.md'), 'utf8');
const prompt = [
`Ticket #${issue.sequence_id}: ${issue.name}`, '',
(issue.description_html || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim() || '(keine Beschreibung)',
].join('\n');
const args = ['-p', prompt, '--append-system-prompt', guardrails, '--permission-mode', 'bypassPermissions',
'--output-format', 'json', '--max-budget-usd', BUDGET_PER_TICKET, '--no-session-persistence'];
const r = spawnSync('claude', args, { cwd: WORK_DIR, encoding: 'utf8', timeout: PER_TICKET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
if (r.error) throw r.error;
let text = r.stdout;
try { text = JSON.parse(r.stdout).result ?? r.stdout; } catch { /* Text-Fallback */ }
return { text, timedOut: r.signal === 'SIGTERM' || r.status === null };
}
function verify() {
const steps = [['pnpm', ['install']], ['pnpm', ['build']], ['pnpm', ['typecheck']], ['pnpm', ['test']]];
for (const [cmd, args] of steps) {
const r = spawnSync(cmd, args, { cwd: WORK_DIR, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) return { ok: false, step: `${cmd} ${args.join(' ')}`, out: (r.stdout + r.stderr).slice(-4000) };
}
return { ok: true };
}
async function handleTicket(issue, states) {
const branch = `agent/${issue.sequence_id}-${slug(issue.name)}`;
log('Ticket', issue.sequence_id, '-', issue.name, '→', branch);
await planeSetState(issue.id, states['In Progress']);
await planeComment(issue.id, `Nacht-Agent beginnt mit diesem Ticket (Zweig <code>${branch}</code>).`);
run('git', ['checkout', '-B', branch]);
let result;
try { result = await runClaudeOnTicket(issue); }
catch (e) { await planeComment(issue.id, `Nacht-Agent abgebrochen (technischer Fehler): ${String(e.message || e).slice(0, 500)}`); return; }
const changed = run('git', ['status', '--porcelain'], { allowFail: true }).stdout.trim().length > 0;
if (!changed) {
await planeComment(issue.id, result.timedOut
? `Nacht-Agent hat die Zeit überschritten, ohne Änderungen zu hinterlassen. Zusammenfassung/letzte Ausgabe:<br>${(result.text || '').slice(0, 1000)}`
: `Nacht-Agent hat keine Änderungen vorgenommen.<br>${extractStatus(result.text).note}`);
run('git', ['checkout', 'main']); run('git', ['branch', '-D', branch], { allowFail: true });
return;
}
run('git', ['add', '-A']);
run('git', ['commit', '-m', `${issue.name} (Nacht-Agent, Ticket #${issue.sequence_id})`], { allowFail: true });
const { status, note } = extractStatus(result.text);
log('Status:', status);
const v = verify();
if (!v.ok) {
run('git', ['push', '--force-with-lease', 'origin', branch]);
await planeComment(issue.id, `Nacht-Agent hat einen Zweig erstellt, aber <strong>Tests/Build schlagen fehl</strong> (${v.step}). Kein Pull Request. Zweig: <code>${branch}</code>. Ende der Ausgabe:<br><pre>${v.out.replace(/</g, '&lt;')}</pre>`);
return;
}
run('git', ['push', '--force-with-lease', 'origin', branch]);
if (status === 'blocked') {
await planeComment(issue.id, `Nacht-Agent kam nicht vollständig durch (Tests sind zwar grün, aber der Agent meldet einen offenen Punkt): ${note}<br>Zweig: <code>${branch}</code> (kein Pull Request, zur Durchsicht).`);
return;
}
const pr = await openPr(branch, `${issue.name} (Ticket #${issue.sequence_id})`,
`Automatisch vom Nacht-Agenten erstellt.\n\n${note}\n\nTests, Typecheck und Build sind grün (siehe Lauf-Protokoll).\n\nPlane: ${PLANE_BASE_URL}/${PLANE_WORKSPACE}/projects/${PLANE_PROJECT_ID}/issues/${issue.id}`);
await planeSetState(issue.id, states['In Review']);
await planeComment(issue.id, `Nacht-Agent fertig. ${note}<br>Pull Request: <a href="${pr.html_url}">${pr.html_url}</a>`);
log('PR eröffnet:', pr.html_url);
}
async function main() {
log('=== Nacht-Agent startet ===');
prepareWorkdir();
const states = await planeStates();
for (const need of ['In Progress', 'In Review']) if (!states[need]) throw new Error(`Plane-Status "${need}" existiert nicht`);
const labelId = await planeLabelId(LABEL_NAME);
if (!labelId) throw new Error(`Label "${LABEL_NAME}" existiert nicht in Plane`);
const pickable = new Set([states['Backlog'], states['Todo']].filter(Boolean));
const r = await plane(`/issues/?per_page=100`);
const all = r.results ?? r;
const candidates = all
.filter((i) => (i.labels || []).includes(labelId) && pickable.has(i.state))
.sort((a, b) => a.sequence_id - b.sequence_id)
.slice(0, MAX_TICKETS);
if (candidates.length === 0) { log('Keine offenen Tickets mit Label "agent" gefunden. Nichts zu tun.'); return; }
log(`${candidates.length} Ticket(s) ausgewählt:`, candidates.map((i) => '#' + i.sequence_id).join(', '));
for (const issue of candidates) {
if (Date.now() > DEADLINE_MS) { log('Zeitfenster erschöpft, breche für heute Nacht ab.'); break; }
try { await handleTicket(issue, states); }
catch (e) { log('Fehler bei Ticket', issue.sequence_id, ':', e.message); await planeComment(issue.id, `Nacht-Agent abgebrochen (technischer Fehler): ${String(e.message || e).slice(0, 500)}`).catch(() => {}); }
}
log('=== Nacht-Agent fertig ===');
}
main().catch((e) => { log('FATAL:', e.stack || e.message); process.exit(1); });

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter API
After=network-online.target mariadb.service
Wants=network-online.target mariadb.service
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=-/etc/kundencenter/backup.env
EnvironmentFile=/etc/kundencenter/app.env
ExecStart=/usr/bin/node dist/server.js
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/kundencenter/requests /var/lib/kundencenter/ticket-attachments
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,7 @@
[Unit]
Description=Kundencenter: Backup-Anfragen aus der Oberflaeche
[Path]
PathExistsGlob=/var/lib/kundencenter/requests/backup-*
Unit=kc-backup-request.service
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter: Backup-Anfrage aus der Oberflaeche abarbeiten
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=NODE_BIN=/usr/bin/node
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/bin/bash /srv/kundencenter/ops/process-requests.sh
TimeoutStartSec=1h

View file

@ -0,0 +1,20 @@
[Unit]
Description=Kundencenter-Backup
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/usr/bin/node dist/cli/index.js backup run
TimeoutStartSec=1h

View file

@ -0,0 +1,8 @@
[Unit]
Description=Taegliches Kundencenter-Backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,20 @@
[Unit]
Description=Kundencenter Wiederherstellungstest
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/usr/bin/node dist/cli/index.js backup restore-test
TimeoutStartSec=1h

View file

@ -0,0 +1,8 @@
[Unit]
Description=Woechentlicher Wiederherstellungstest
[Timer]
OnCalendar=Sun *-*-* 04:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,19 @@
[Unit]
Description=Kundencenter Web
After=network-online.target kc-api.service
Wants=network-online.target
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/web
Environment=NODE_ENV=production
Environment=NEXT_TELEMETRY_DISABLED=1
ExecStart=/usr/bin/node node_modules/next/dist/bin/next start -p 4101 -H 127.0.0.1
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/kundencenter/apps/web/.next
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter Worker (Jobs, Discord-Bot)
After=network-online.target mariadb.service kc-api.service
Wants=network-online.target mariadb.service
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/worker
Environment=KC_ENV_DIR=/nonexistent
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=-/etc/kundencenter/backup.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/discord.env
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -1,7 +1,7 @@
{
"name": "kundencenter",
"private": true,
"version": "0.1.0",
"version": "0.0.1",
"packageManager": "pnpm@10.14.0",
"engines": { "node": ">=22" },
"scripts": {

View file

@ -0,0 +1,110 @@
import { ConnectorError, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
import { mapLicense, type RawActivation, type RawGroup, type RawLicense, type RawProgram } from './index.js';
/**
* Verwaltungs-Client für die Lizenzverwaltung im Kundencenter (Übersicht, Vergabe, Aktivierungen, Entitlements,
* Lebenszyklus). Ergänzt den Connector, der nur den allgemeinen Ressourcen-Vertrag abdeckt. Benötigt einen
* Service-Token (Scopes licenses:*, activations:reset, programs:read, products:read) oder API-Benutzer.
* Fehlermeldungen des Lizenzsystems (detail) werden als ConnectorError INVALID_INPUT/CONFLICT weitergereicht,
* damit die Oberfläche sie anzeigen kann (z. B. "Trial existiert bereits").
*/
export interface LicenseActivation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string; active: boolean }
export interface LicenseDetail {
resource: NormalizedResource; raw: RawLicense; activations: LicenseActivation[];
entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number };
limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null };
origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null };
}
export interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: 'LICENSED' | 'ADDON' | string; durationType: string; price: string | null; currency: string | null; planKey: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; active: boolean }
export interface LicensingCatalog { programs: { id: number; name: string }[]; products: CatalogProduct[] }
const mask = (v: string | null | undefined) => (v ? (v.length <= 8 ? '****' : `${v.slice(0, 4)}…${v.slice(-4)}`) : null);
const tokenCache = new Map<string, { token: string; at: number }>();
const splitModules = (v: string | null | undefined): string[] => String(v ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
export function createLicensingAdmin(ctx: ConnectorContext, fetchImpl: typeof fetch = fetch) {
const base = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(base)) throw new ConnectorError('BAD_CONFIG', 'baseUrl fehlt');
if (!ctx.secrets.token && !(ctx.secrets.username && ctx.secrets.password)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
async function auth(force = false): Promise<string> {
if (ctx.secrets.token) return `Bearer ${ctx.secrets.token}`;
const key = `${base}|${ctx.secrets.username}|${ctx.secrets.password}`; const c = tokenCache.get(key);
if (!force && c && Date.now() - c.at < 20 * 60_000) return `Bearer ${c.token}`;
const r = await fetchImpl(`${base}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: ctx.secrets.username!, password: ctx.secrets.password! }).toString() });
const j = await r.json().catch(() => null) as { access_token?: string } | null;
if (!r.ok || !j?.access_token) throw new ConnectorError('AUTH_FAILED', `HTTP ${r.status}`);
tokenCache.set(key, { token: j.access_token, at: Date.now() });
return `Bearer ${j.access_token}`;
}
/** Fehlertext des Lizenzsystems (FastAPI: detail als Text, Objekt oder Liste von Validierungsfehlern). */
const detailOf = (b: unknown): string | undefined => {
const d = (b as { detail?: unknown } | null)?.detail;
if (typeof d === 'string') return d.slice(0, 300);
if (Array.isArray(d)) return d.map((x) => `${Array.isArray(x?.loc) ? x.loc.slice(1).join('.') : ''}: ${x?.msg ?? ''}`).join('; ').slice(0, 300);
if (d && typeof d === 'object' && typeof (d as { message?: unknown }).message === 'string') return String((d as { message: string }).message).slice(0, 300);
return undefined;
};
async function call<T>(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown, headers: Record<string, string> = {}, retried = false): Promise<T> {
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15_000);
let res: Response;
try {
res = await fetchImpl(`${base}${path}`, { method, signal: ctl.signal, headers: { accept: 'application/json', authorization: await auth(), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined });
} catch (e) {
throw (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT') : new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message);
} finally { clearTimeout(timer); }
if (res.status === 401 && !retried && !ctx.secrets.token) { await auth(true); return call<T>(method, path, body, headers, true); }
if (res.status === 204) return undefined as T;
const json = await res.json().catch(() => null);
if (res.ok) return json as T;
const detail = detailOf(json);
if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`);
if (res.status === 404) throw new ConnectorError('NOT_FOUND', detail);
if (res.status === 409) throw new ConnectorError('CONFLICT', detail);
if (res.status === 400 || res.status === 422) throw new ConnectorError('INVALID_INPUT', detail);
if (res.status === 429) throw new ConnectorError('RATE_LIMITED');
throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
}
let programNames: Promise<Map<number, string>> | null = null;
const programs = () => (programNames ??= call<RawProgram[]>('GET', '/programs/?limit=1000').then((p) => new Map((Array.isArray(p) ? p : []).map((x) => [x.id, x.name]))));
const normalize = async (l: RawLicense) => mapLicense(l, await programs(), 'UTC'); // Lizenzsysteme mit Verwaltungs-API liefern UTC (utc_timestamps)
const activation = (a: RawActivation): LicenseActivation => ({
id: Number(a.id), instanceId: a.instance_id ?? null, hardwareIdMasked: mask(a.hardware_id), environment: a.environment ?? null, lastSeenIp: a.last_seen_ip ?? null,
productVersion: a.product_version ?? null, activatedAt: a.activated_at, lastSeenAt: a.last_seen_at, active: a.is_active !== false,
});
const lid = (id: string | number) => encodeURIComponent(String(id));
return {
normalize,
async get(id: string | number): Promise<LicenseDetail> {
const raw = await call<RawLicense>('GET', `/licenses/${lid(id)}`);
if (!raw || typeof raw.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
const acts = await call<RawActivation[]>('GET', `/licenses/${lid(id)}/activations`).catch(() => raw.activations ?? []);
return {
resource: await normalize(raw), raw: { ...raw, license_key: '' }, // Schlüssel nie mitgeben (Abruf nur über reveal)
activations: (Array.isArray(acts) ? acts : []).filter((a) => a.is_active !== false).map(activation),
entitlement: { plan: raw.plan_key ?? null, modules: raw.modules ?? [], customerLimit: raw.customer_limit ?? null, version: raw.entitlement_version ?? 0 },
limits: { maxActivations: raw.max_activations ?? null, activationLimit: raw.activation_limit ?? null, userLimit: raw.user_limit ?? null, graceDays: raw.grace_days ?? null, effectiveGraceDays: raw.effective_grace_days ?? null },
origin: { source: raw.source ?? null, orderRef: raw.order_ref ?? null, externalRef: raw.external_ref ?? null, customerName: raw.customer_name ?? null, customerEmail: raw.customer_email ?? null, createdAt: raw.created_at ?? null },
};
},
/** Programme und Produkte (inkl. Add-on-Produkte) für die Vergabe. */
async catalog(): Promise<LicensingCatalog> {
const [p, g] = await Promise.all([call<RawProgram[]>('GET', '/programs/?limit=1000'), call<RawGroup[]>('GET', '/products/groups')]);
const products: CatalogProduct[] = [];
for (const grp of Array.isArray(g) ? g : []) for (const x of grp.products ?? []) {
products.push({ id: x.id, name: x.name, groupName: grp.name, programId: grp.program_id, type: x.product_type ?? 'LICENSED', durationType: x.duration_type ?? 'MONTH', price: x.price != null ? String(x.price) : null, currency: x.currency ?? null,
planKey: x.plan_key ?? null, modules: splitModules(x.modules), customerLimit: x.customer_limit ?? null, userLimit: x.user_limit ?? null, active: x.is_active !== false && grp.is_active !== false });
}
return { programs: (Array.isArray(p) ? p : []).map((x) => ({ id: x.id, name: x.name })), products };
},
create: (body: Record<string, unknown>) => call<RawLicense>('POST', '/licenses/', body),
createTrial: (body: Record<string, unknown>) => call<RawLicense>('POST', '/licenses/trials', body),
update: (id: string | number, body: Record<string, unknown>) => call<RawLicense>('PUT', `/licenses/${lid(id)}`, body),
entitlement: (id: string | number, body: Record<string, unknown>) => call<RawLicense>('POST', `/licenses/${lid(id)}/entitlement`, body),
lifecycle: (id: string | number, action: 'suspend' | 'unsuspend' | 'extend' | 'revoke', body: Record<string, unknown>, idempotencyKey: string) =>
call<{ changed: boolean; license: RawLicense }>('POST', `/licenses/${lid(id)}/${action}`, body, { 'Idempotency-Key': idempotencyKey }),
deleteActivation: (id: string | number, activationId: number) => call<void>('DELETE', `/licenses/${lid(id)}/activations/${encodeURIComponent(String(activationId))}`),
};
}
export type LicensingAdmin = ReturnType<typeof createLicensingAdmin>;

View file

@ -1,15 +1,18 @@
import { ConnectorError, CONTRACT_VERSION, httpJson, maskKey, type ActionName, type Capability, type CatalogItem, type Connector, type ConnectorContext, type HttpOptions, type NormalizedResource, type ResourceState } from '@kc/connector-sdk';
/** Rohdaten des eigenen Lizenzsystems (FastAPI, siehe /var/www/html/licensing). Bleiben im Connector. */
interface RawActivation { hardware_id: string; ip_address?: string | null; last_seen_ip?: string | null; activated_at: string; last_seen_at: string }
interface RawLicense {
export interface RawActivation { id?: number; hardware_id: string | null; instance_id?: string | null; environment?: string | null; ip_address?: string | null; last_seen_ip?: string | null; product_version?: string | null; is_active?: boolean; activated_at: string; last_seen_at: string }
export interface RawLicense {
id: number; program_id: number; product_id?: number | null; license_key: string; user_limit: number | null; duration_type: string; starts_at: string | null; expires_at: string | null; is_active: boolean;
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; license_type?: string | null; activation_limit?: number | null;
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null } | null;
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; revoke_reason?: string | null; license_type?: string | null; activation_limit?: number | null; max_activations?: number | null;
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null; product_type?: string | null } | null;
plan_key?: string | null; modules?: string[] | null; customer_limit?: number | null; entitlement_version?: number | null; parent_license_id?: number | null; trial_pending?: boolean | null;
grace_days?: number | null; effective_grace_days?: number | null; source?: string | null; customer_name?: string | null; customer_email?: string | null; order_ref?: string | null; external_ref?: string | null;
last_check_at?: string | null; created_at?: string | null;
}
interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null }
interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
interface RawProgram { id: number; name: string; description?: string | null }
export interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null; product_type?: string | null; plan_key?: string | null; customer_limit?: number | null }
export interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
export interface RawProgram { id: number; name: string; description?: string | null }
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
export function localToUtcIso(naive: string | null | undefined, tz: string): string | null {
@ -31,6 +34,32 @@ const toLocalNaive = (iso: string, tz: string): string => {
/** Edition wie im Familytool: Präfix des Schlüssels (PREMIUM-, TRIAL-, LIFETIME…), sonst UNLIMITED. */
export const editionOf = (key: string): string => { const k = key.toUpperCase(); return k.startsWith('LIFETIME') ? 'LIFETIME' : k.startsWith('PREMIUM-') ? 'PREMIUM' : k.startsWith('TRIAL-') ? 'TRIAL' : 'UNLIMITED'; };
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
function stateOf(l: RawLicense, until: string | null, now: Date): ResourceState {
const st = String(l.status ?? '').toLowerCase();
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
if (st === 'expired' || (until && new Date(until) < now)) return 'expired';
return 'active';
}
/** Normalisiert eine Lizenz des Lizenzsystems (für Abgleich, Bereitstellung und die Lizenzverwaltung). */
export function mapLicense(l: RawLicense, programs: Map<number, string>, zone: string, now: Date = new Date()): NormalizedResource {
const until = localToUtcIso(l.expires_at, zone);
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
const act = l.activation ?? l.activations?.[0] ?? null;
return {
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until, now),
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
limits: { users: l.user_limit },
details: {
program, programId: l.program_id, product: l.product?.name ?? null, productId: l.product_id ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
providerStatus: l.status ?? null, revoked: !!l.revoked_at, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
plan: l.plan_key ?? null, customerLimit: l.customer_limit ?? null, parentLicenseId: l.parent_license_id ?? null, addon: l.parent_license_id != null || l.product?.product_type === 'ADDON',
trial: l.duration_type === 'TRIAL', trialPending: !!l.trial_pending,
activation: act ? { hardwareIdMasked: act.hardware_id ? maskKey(act.hardware_id) : null, activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
},
};
}
export interface LicensingDeps extends HttpOptions { now?: () => Date }
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
const PREFIXES = ['PREMIUM', 'TRIAL', 'LIFETIME'];
@ -78,28 +107,7 @@ export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
catch (e) { if (e instanceof ConnectorError && e.code === 'AUTH_FAILED' && hasAuth(ctx) && !hasToken(ctx)) return go(true); throw e; }
}
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
const stateOf = (l: RawLicense, until: string | null): ResourceState => {
const st = String(l.status ?? '').toLowerCase();
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
if (st === 'expired' || (until && new Date(until) < now())) return 'expired';
return 'active';
};
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => {
const until = localToUtcIso(l.expires_at, zone);
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
const act = l.activation ?? l.activations?.[0] ?? null;
return {
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until),
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
limits: { users: l.user_limit },
details: {
program, product: l.product?.name ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
providerStatus: l.status ?? null, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
activation: act ? { hardwareIdMasked: maskKey(act.hardware_id), activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
},
};
};
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => mapLicense(l, programs, zone, now());
return {
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
@ -235,3 +243,4 @@ export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
};
}
export const licensingConnector = createLicensingConnector();
export * from './admin.js';

View file

@ -60,6 +60,8 @@ export async function syncInstance(instanceId: string, correlationId: string): P
}
}
/** Ressource anlegen/aktualisieren (Abgleich, Bereitstellung, Lizenzvergabe). Liefert die lokale ID. */
export async function upsertResource(instanceId: string, r: NormalizedResource): Promise<string> { return upsert(instanceId, r); }
async function upsert(instanceId: string, r: NormalizedResource): Promise<string> {
await run(
`INSERT INTO resources (id, instance_id, external_ref, type, name, state, valid_from, valid_until, data_json, synced_at)
@ -111,6 +113,7 @@ export async function scheduleDueSyncs(enqueue: (type: string, payload: unknown,
return due.length;
}
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
export { createLicensingAdmin, type LicensingAdmin, type LicenseDetail, type LicensingCatalog } from '@kc/connector-licensing';
export type { ActionName, Capability };

View file

@ -36,22 +36,24 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void>
const c = conn ?? (await pool.getConnection());
try {
if (own) await c.beginTransaction();
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
const [[lockRow]] = await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_audit_chain']) as any;
if (Number(lockRow?.got) !== 1) throw new Error('Audit-Sperre konnte nicht erlangt werden (Zeitüberschreitung oder Fehler)');
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
const prev = last?.hash ?? '0'.repeat(64);
const ts = new Date();
// hash_version 2: connector/ip fließen mit ein (Reihenfolge muss zu verifyAuditChain passen).
const body = {
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
error_class: e.errorClass ?? null, connector: e.connector ?? null, correlation_id: e.correlationId ?? null, ip: e.ip ?? null,
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
};
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
await c.execute(
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash, hash_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, body.connector,
body.correlation_id, body.ip, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
);
if (own) await c.commit();
} catch (err) {
@ -63,14 +65,22 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void>
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null.
* Beginnt nicht immer bei der Genesis-Null: wurden ältere Einträge per Aufbewahrungsfrist gelöscht
* (purgeAuditRetention), beginnt die Prüfung stattdessen beim Checkpoint-Hash der zuletzt gelöschten Zeile –
* nur wenn die aktuell erste Zeile auch wirklich genau darauf verweist, sonst bliebe eine echte Manipulation
* unentdeckt, die sich als "war nur die Aufräumung" tarnt. */
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash, hash_version FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
const [checkpoint] = await q('SELECT checkpoint_hash FROM audit_retention_checkpoints ORDER BY id DESC LIMIT 1').catch(() => [] as any[]);
if (checkpoint && rows[0] && rows[0].prev_hash === checkpoint.checkpoint_hash) prev = checkpoint.checkpoint_hash;
for (const r of rows) {
const body = {
// Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein.
const body: Record<string, unknown> = {
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class,
...(Number(r.hash_version ?? 1) >= 2 ? { connector: r.connector, correlation_id: r.correlation_id, ip: r.ip } : { correlation_id: r.correlation_id }),
before: r.before_json ?? null, after: r.after_json ?? null,
};
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
@ -79,3 +89,42 @@ export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql
}
return { checked: rows.length, brokenAt: null };
}
export interface RetentionResult { purged: number; retentionDays: number; purgedThroughId: number | null }
/** Löscht Audit-Einträge, die älter als die konfigurierte Aufbewahrungsfrist sind, und hinterlässt einen
* Checkpoint, damit die Hash-Kette danach weiter überprüfbar bleibt (siehe verifyAuditChain).
* audit_events ist per Trigger unveränderlich (Migration 033) – dafür wird der DELETE-Trigger kurz entfernt und
* sofort wieder angelegt, alles unter derselben Sperre wie audit(), damit kein gleichzeitiger Schreibzugriff
* in das kurze Zeitfenster ohne Trigger fällt. Löscht nur, wenn die Kette vorher nachweislich intakt ist. */
export async function purgeAuditRetention(retentionDays: number): Promise<RetentionResult> {
const c = await pool.getConnection();
try {
const [[lockRow]] = await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_audit_chain']) as any;
if (Number(lockRow?.got) !== 1) throw new Error('Audit-Sperre konnte nicht erlangt werden (Zeitüberschreitung oder Fehler)');
try {
const chain = await verifyAuditChain((sql) => c.query(sql).then(([r]) => r as any[]));
if (chain.brokenAt !== null) throw new Error(`Aufräumung abgebrochen: Hash-Kette ist bereits ab Eintrag ${chain.brokenAt} fehlerhaft`);
const [[cutoffRow]] = await c.query('SELECT MAX(id) AS purgeId FROM audit_events WHERE ts < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? DAY)', [retentionDays]) as any;
const purgeId = cutoffRow?.purgeId as number | null;
if (purgeId === null) return { purged: 0, retentionDays, purgedThroughId: null };
// Nichts darf übersprungen werden: die nächste verbleibende Zeile muss lückenlos direkt anschließen.
const [[gapCheck]] = await c.query('SELECT MIN(id) AS nextId FROM audit_events WHERE id > ?', [purgeId]) as any;
if (gapCheck?.nextId != null && Number(gapCheck.nextId) !== purgeId + 1) throw new Error('Aufräumung abgebrochen: Lücke zwischen zu löschenden und verbleibenden Einträgen (unerwartete Zeitstempel-Reihenfolge)');
const [[last]] = await c.query('SELECT hash FROM audit_events WHERE id = ?', [purgeId]) as any;
const [[countRow]] = await c.query('SELECT COUNT(*) AS n FROM audit_events WHERE id <= ?', [purgeId]) as any;
await c.query('DROP TRIGGER audit_events_no_delete');
try {
await c.query('DELETE FROM audit_events WHERE id <= ?', [purgeId]);
await c.query('INSERT INTO audit_retention_checkpoints (purged_count, purged_through_id, checkpoint_hash, retention_days) VALUES (?,?,?,?)', [countRow.n, purgeId, last.hash, retentionDays]);
} finally {
await c.query("CREATE TRIGGER audit_events_no_delete BEFORE DELETE ON audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_events ist unveraenderlich (append-only) - DELETE nicht erlaubt'");
}
// Eigene Verbindung übergeben (nicht own=true lassen): dieselbe Sitzung hält die Sperre schon re-entrant,
// eine frisch aus dem Pool geholte Verbindung würde auf genau diese Sperre warten und nach 10s scheitern.
await audit({ actorType: 'system', action: 'audit.retention_purge', resourceType: 'audit_events', after: { purgedCount: countRow.n, purgedThroughId: purgeId, retentionDays } }, c);
return { purged: Number(countRow.n), retentionDays, purgedThroughId: purgeId };
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
}
} finally { c.release(); }
}

View file

@ -27,6 +27,11 @@ export const config = {
/** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */
allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]),
secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'),
// Identifiziert das Programm "Kundencenter" selbst gegenüber licensing.flessinglabs.com (kein echtes
// Geheimnis, steckt in jeder Installation gleich). Der je Installation unterschiedliche Lizenzschlüssel
// liegt dagegen verschlüsselt in der DB (Einstellungen > Lizenz), nicht hier.
licenseProgramKey: process.env.LICENSE_PROGRAM_KEY || null,
licenseApiBaseUrl: process.env.LICENSE_API_BASE_URL || 'https://licensing.flessinglabs.com/api',
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),

View file

@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from './db.js';
import { randomToken, sha256 } from './crypto.js';
import { sendTemplateMail } from './mail.js';
import { audit } from './audit.js';
import { addMonths, effectiveCancelDate } from './contractterms.js';
import { config } from './config.js';
/** Erinnerung vor Vertragsende ("Behalten"/"Kündigen"), unabhängig davon, ob der Vertrag automatisch
* verlängert (renewal='auto') oder sonst ausläuft (renewal='none') - siehe docs/Diskussion zu #35-Folgeticket.
* Nicht zu verwechseln mit processContractLifecycle() (@kc/connectors), die die eigentliche Verlängerung/
* Beendigung zum Laufzeitende durchführt; diese Erinnerung läuft zeitlich VOR dieser Entscheidung und gibt
* dem Kunden die Möglichkeit, aktiv "Kündigen" zu wählen (inkl. Ticket für die externe Abmeldung, z. B. beim
* Domain-Registrar) oder "Behalten" zu wählen (verlängert bewusst um eine Laufzeit, auch bei renewal='none'). */
const REMINDER_DAYS_BEFORE = 30;
const TOKEN_TTL_DAYS = 45; // deckt die Frist vor Laufzeitende plus Puffer danach ab
export interface ReminderRunResult { sent: number; skipped: number }
/** Findet fällige Verträge und sendet je eine Erinnerungsmail mit Entscheidungslinks; markiert
* renewal_reminder_sent_at, damit das nur einmal passiert (idempotent, täglich vom Worker aufgerufen). */
export async function sendRenewalReminders(now: Date = new Date()): Promise<ReminderRunResult> {
const rows = await query(
`SELECT c.id, c.number, c.org_id, c.term_end, c.price_snapshot_json, g.name AS org_name, b.billing_email, b.contact_name
FROM contracts c JOIN organizations g ON g.id = c.org_id LEFT JOIN billing_profiles b ON b.org_id = g.id
WHERE c.status IN ('active','suspended') AND c.term_end IS NOT NULL AND c.cancel_requested_at IS NULL
AND c.renewal_reminder_sent_at IS NULL AND c.term_end <= DATE_ADD(?, INTERVAL ? DAY)`,
[now, REMINDER_DAYS_BEFORE],
);
let sent = 0; let skipped = 0;
for (const c of rows) {
if (!c.billing_email) { skipped++; continue; }
const token = randomToken(32);
await run('INSERT INTO contract_renewal_tokens (id, contract_id, token_hash, expires_at) VALUES (?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? DAY))', [randomUUID(), c.id, sha256(token), TOKEN_TTL_DAYS]);
const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
const link = (action: 'keep' | 'cancel') => `${config.baseUrl}/vertrag-entscheidung?token=${encodeURIComponent(token)}&action=${action}`;
const status = await sendTemplateMail(c.billing_email, 'contract_renewal_reminder', {
name: c.contact_name || c.org_name, contractNumber: c.number, productName: snap?.name ?? 'Ihr Vertrag',
termEnd: new Date(c.term_end).toLocaleDateString('de-DE'), keepLink: link('keep'), cancelLink: link('cancel'),
});
await run('UPDATE contracts SET renewal_reminder_sent_at = ? WHERE id = ?', [now, c.id]);
if (status === 'sent') sent++; else skipped++;
}
return { sent, skipped };
}
export interface RenewalDecisionInfo { contractId: string; contractNumber: string; orgName: string; productName: string; termEnd: string }
/** Nur lesen (für die Bestätigungsseite), verbraucht den Token NICHT - das macht erst applyRenewalDecision(). */
export async function peekRenewalToken(token: string): Promise<RenewalDecisionInfo | null> {
const t = await one('SELECT contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token)]);
if (!t) return null;
const c = await one('SELECT c.id, c.number, c.term_end, c.price_snapshot_json, g.name AS org_name FROM contracts c JOIN organizations g ON g.id = c.org_id WHERE c.id = ?', [t.contract_id]);
if (!c) return null;
const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
return { contractId: c.id, contractNumber: c.number, orgName: c.org_name, productName: snap?.name ?? 'Vertrag', termEnd: new Date(c.term_end).toISOString() };
}
export class RenewalTokenError extends Error {}
/** Verbraucht den Token race-sicher (wie consumeToken bei Einladung/Passwort-Reset) und führt die Entscheidung aus:
* "keep" verlängert um eine Laufzeit (bzw. nimmt eine bereits gestellte Kündigung zurück, falls vorhanden);
* "cancel" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung und legt zusätzlich ein internes
* Ticket an, damit Personal die externe Abmeldung (z. B. Domain-Registrar) tatsächlich vornimmt. */
export async function applyRenewalDecision(token: string, action: 'keep' | 'cancel'): Promise<{ contractNumber: string }> {
return tx(async (c) => {
const hash = sha256(token);
const t = await one('SELECT id, contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3) FOR UPDATE', [hash], c);
if (!t) throw new RenewalTokenError('Link ungültig oder abgelaufen');
const upd = await run('UPDATE contract_renewal_tokens SET used_at = UTC_TIMESTAMP(3), used_action = ? WHERE id = ? AND used_at IS NULL', [action, t.id], c);
if (upd.affectedRows !== 1) throw new RenewalTokenError('Link wurde bereits verwendet');
const contract = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [t.contract_id], c);
if (!contract) throw new RenewalTokenError('Vertrag nicht gefunden');
if (!['active', 'suspended'].includes(contract.status)) throw new RenewalTokenError('Dieser Vertrag ist nicht mehr aktiv');
if (action === 'keep') {
if (contract.cancel_requested_at) {
await run('UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ?', [contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: contract.id, after: { via: 'renewal_reminder' } }, c);
} else {
// Immer um mindestens eine Laufzeit verlängern (anders als renewedTermEnd/processContractLifecycle, die
// nur ein bereits VERGANGENES Laufzeitende aufholen - hier liegt term_end zum Zeitpunkt der Erinnerung
// noch in der Zukunft, genau das soll die Entscheidung "Behalten" ja bewirken).
const months = Number(contract.renewal_term_months) > 0 ? Number(contract.renewal_term_months) : 12;
let end = addMonths(new Date(contract.term_end ?? new Date()), months);
let guard = 0; while (end <= new Date() && guard++ < 1200) end = addMonths(end, months); // sehr späte Entscheidung: weiter aufholen
await run('UPDATE contracts SET term_end = ? WHERE id = ?', [end, contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.renew', resourceType: 'contract', resourceId: contract.id, after: { termEnd: end.toISOString(), via: 'renewal_reminder' } }, c);
}
} else {
if (!contract.cancel_requested_at) {
const now = new Date();
const effective = effectiveCancelDate(now, { termEnd: contract.term_end ? new Date(contract.term_end) : null, renewal: contract.renewal, renewalTermMonths: Number(contract.renewal_term_months), noticeDays: Number(contract.notice_days) });
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ?', [now, effective, contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: contract.id, after: { effectiveAt: effective.toISOString(), via: 'renewal_reminder' } }, c);
}
const owner = await one("SELECT user_id FROM memberships WHERE org_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [contract.org_id], c);
if (owner) {
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c);
const num = await one('SELECT LAST_INSERT_ID() AS n', [], c);
const ticketId = randomUUID(); const ticketNumber = `T-${num!.n}`;
await run('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)',
[ticketId, ticketNumber, contract.org_id, `Kündigung über Erinnerungsmail: Vertrag ${contract.number}`, 'pending_staff', 'normal', owner.user_id], c);
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,1)',
[randomUUID(), ticketId, owner.user_id, 'customer', `Der Kunde hat über die Erinnerungsmail zum Vertrag ${contract.number} "Kündigen" gewählt. Bitte die externe Abmeldung vornehmen (z. B. Domain beim Registrar kündigen) und das Ticket danach schließen.`], c);
}
}
return { contractNumber: contract.number as string };
});
}

View file

@ -0,0 +1,202 @@
import { createPublicKey, createVerify } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { config } from './config.js';
import { one, query, run } from './db.js';
import { encrypt, decrypt } from './crypto.js';
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com (Meilenstein
* "Kundencenter lizenzierbar machen"). Nicht zu verwechseln mit dem "licensing"-Connector, der
* KUNDEN-Lizenzen verwaltet, die das Kundencenter weiterverkauft. */
const OFFLINE_GRACE_DAYS = Number(process.env.LICENSE_OFFLINE_GRACE_DAYS ?? 7);
export interface VerifyResult {
valid: boolean; message: string | null; plan: string | null; modules: string[];
expiresAt: string | null; userLimit: number | null; customerLimit: number | null;
entitlementVersion: number | null; trial: boolean; addons: unknown[]; serverTime: string | null;
signature: string | null; keyId: string | null;
}
interface SigningKey { kid: string; alg: string; n: string; e: string; status: string }
// Buffer.from(hex, 'hex') verwirft bei ungerader Länge stillschweigend die letzte Ziffer (z. B. würde der
// Exponent "10001" = 65537 fälschlich zu 0x1000 statt 0x010001) - deshalb vorher auf gerade Länge auffüllen.
const hexToB64url = (hex: string): string => Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex').toString('base64url');
/** FLS1.<base64url(Payload)>.<base64url(Signatur)>, RSA-2048 PKCS#1 v1.5 SHA-256 über die ASCII-Bytes
* des Payload-Teils (siehe licensing INTEGRATION.md, Abschnitt "Signierte Antworten"). */
function verifyFls1(token: string, keys: SigningKey[]): { ok: boolean; payload?: Record<string, unknown> } {
const parts = token.split('.');
if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false };
const [, payloadB64, sigB64] = parts;
let payload: Record<string, unknown>;
try { payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString('utf8')); } catch { return { ok: false }; }
const key = keys.find((k) => k.kid === payload.kid);
if (!key) return { ok: false };
try {
const pub = createPublicKey({ key: { kty: 'RSA', n: hexToB64url(key.n), e: hexToB64url(key.e) }, format: 'jwk' });
const v = createVerify('RSA-SHA256'); v.update(payloadB64!, 'ascii'); v.end();
return { ok: v.verify(pub, Buffer.from(sigB64!, 'base64url')), payload };
} catch { return { ok: false }; }
}
async function loadSigningKeys(): Promise<SigningKey[]> {
const rows = await query('SELECT kid, alg, n_hex, e_hex, status FROM license_signing_keys');
return rows.map((r) => ({ kid: r.kid, alg: r.alg, n: r.n_hex, e: r.e_hex, status: r.status }));
}
/** Schlüssel nur ERGÄNZEN, nie anhand der Serverantwort löschen (sonst könnte ein vorgetäuschter
* Server die Liste auf nur noch seine eigenen Schlüssel "bereinigen"). */
async function refreshSigningKeys(): Promise<void> {
const r = await fetch(`${config.licenseApiBaseUrl}/license/signing-keys`, { signal: AbortSignal.timeout(10000) });
if (!r.ok) return;
const data = await r.json() as { format?: string; keys?: { kid: string; alg: string; n: string; e: string; status: string }[] };
if (data.format !== 'FLS1' || !Array.isArray(data.keys)) return;
for (const k of data.keys) await run('INSERT INTO license_signing_keys (kid, alg, n_hex, e_hex, status) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE alg=VALUES(alg), n_hex=VALUES(n_hex), e_hex=VALUES(e_hex), status=VALUES(status)', [k.kid, k.alg, k.n, k.e, k.status]);
}
export class LicenseCheckError extends Error {}
/** Fragt /license/verify live ab, prüft die Signatur und schreibt das Ergebnis in license_state.
* Wirft bei fehlender Konfiguration oder Netzwerkfehler (ruft NICHT selbst den gecachten Zustand ab -
* das macht getEntitlement()). */
export async function checkLicenseNow(): Promise<VerifyResult> {
if (!config.licenseProgramKey) throw new LicenseCheckError('LICENSE_PROGRAM_KEY ist nicht konfiguriert');
const row = await one('SELECT license_key_enc, instance_id FROM license_state WHERE id = 1');
if (!row) throw new LicenseCheckError('license_state fehlt (Migration nicht angewendet?)');
if (!row.license_key_enc) throw new LicenseCheckError('Kein Lizenzschlüssel hinterlegt (Einstellungen > Lizenz)');
const licenseKey = decrypt(row.license_key_enc);
await run('UPDATE license_state SET last_attempt_at = UTC_TIMESTAMP(3) WHERE id = 1');
let res: Response;
try {
res = await fetch(`${config.licenseApiBaseUrl}/license/verify`, {
method: 'POST', headers: { 'content-type': 'application/json', 'X-Program-Key': config.licenseProgramKey },
body: JSON.stringify({ licenseKey, instanceId: row.instance_id }), signal: AbortSignal.timeout(15000),
});
} catch (e) {
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [`Netzwerkfehler: ${(e as Error).message}`.slice(0, 500)]);
throw new LicenseCheckError(`Lizenzserver nicht erreichbar: ${(e as Error).message}`);
}
if (res.status === 401) { await run('UPDATE license_state SET last_error = ? WHERE id = 1', ['Program-Key ungültig (LICENSE_PROGRAM_KEY)']); throw new LicenseCheckError('Program-Key ungültig'); }
if (!res.ok) { const msg = `Lizenzserver: HTTP ${res.status}`; await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]); throw new LicenseCheckError(msg); }
const data = await res.json() as VerifyResult;
if (data.signature) {
let keys = await loadSigningKeys();
let v = verifyFls1(data.signature, keys);
if (!v.ok) { await refreshSigningKeys().catch(() => undefined); keys = await loadSigningKeys(); v = verifyFls1(data.signature, keys); } // evtl. rotierter, noch unbekannter Schlüssel
if (!v.ok || v.payload?.licenseKey !== licenseKey) {
const msg = 'Signaturprüfung der Lizenzantwort fehlgeschlagen';
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]);
throw new LicenseCheckError(msg);
}
}
await run(
`UPDATE license_state SET valid=?, message=?, plan=?, modules_json=?, expires_at=?, user_limit=?, customer_limit=?,
entitlement_version=?, is_trial=?, addons_json=?, server_time=?, checked_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id=1`,
[data.valid, data.message ?? null, data.plan ?? null, JSON.stringify(data.modules ?? []), data.expiresAt ? new Date(data.expiresAt) : null,
data.userLimit ?? null, data.customerLimit ?? null, data.entitlementVersion ?? null, !!data.trial, JSON.stringify(data.addons ?? []),
data.serverTime ? new Date(data.serverTime) : null],
);
return data;
}
export interface Entitlement {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: Date | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: Date | null; message: string | null;
}
/** Liefert den aktuell nutzbaren Entitlement-Stand: zuletzt geprüfter (signaturgeprüfter) Snapshot, solange
* er innerhalb des Offline-Fensters liegt (min. Ablaufdatum der Lizenz, min. eigenes Offline-Fenster ab der
* letzten erfolgreichen Prüfung) - verlängert nie eine tatsächlich abgelaufene Lizenz. Ruft NICHT selbst
* die Live-API auf (das übernimmt der Worker periodisch bzw. checkLicenseNow() explizit). */
export async function getEntitlement(): Promise<Entitlement> {
if (!config.licenseProgramKey) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'LICENSE_PROGRAM_KEY nicht konfiguriert' };
const row = await one('SELECT * FROM license_state WHERE id = 1');
if (!row || !row.license_key_enc) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'Kein Lizenzschlüssel hinterlegt' };
if (!row.checked_at) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: true, source: 'unchecked', checkedAt: null, message: 'Noch nicht geprüft' };
const checkedAt = new Date(row.checked_at as Date);
const graceUntil = new Date(checkedAt.getTime() + OFFLINE_GRACE_DAYS * 86400000);
const expiresAt = row.expires_at ? new Date(row.expires_at as Date) : null;
const now = new Date();
const withinOfflineGrace = now <= graceUntil && (expiresAt === null || now <= expiresAt);
const modules = (typeof row.modules_json === 'string' ? JSON.parse(row.modules_json) : row.modules_json) ?? [];
return {
valid: !!row.valid && withinOfflineGrace, plan: row.plan, modules, customerLimit: row.customer_limit, userLimit: row.user_limit,
trial: !!row.is_trial, expiresAt, configured: true, source: withinOfflineGrace ? 'live' : 'offline-grace', checkedAt, message: row.message,
};
}
// ---- Edition: Testmodus ohne Lizenz, voller Umfang mit Lizenz ---------------------------------------------------
/** Funktionen, die eine Lizenz voraussetzen. Modul-Schlüssel im Lizenzsystem (Produkt/Lizenz "modules"). */
export const LICENSED_FEATURES = ['discord', 'imap', 'datev', 'backup_remote'] as const;
export type LicensedFeature = (typeof LICENSED_FEATURES)[number];
export const FEATURE_LABEL: Record<LicensedFeature, string> = { discord: 'Discord-Bot', imap: 'E-Mail-Posteingang (IMAP)', datev: 'DATEV-Export', backup_remote: 'Backups auf externe Ziele' };
/** Grenzen des Testmodus (ohne gültige Lizenz). Bestehende Daten bleiben unangetastet, nur Neuanlagen werden begrenzt. */
export const TRIAL_LIMITS = { staff: 1, customers: 2 } as const;
export interface Edition {
mode: 'licensed' | 'trial'; features: LicensedFeature[]; customerLimit: number | null; staffLimit: number | null;
/** Warum Testmodus (z. B. kein Schlüssel, abgelaufen); bei Lizenz der Plan. */ reason: string | null;
}
/** Maßgebliche Edition dieser Installation. Gültige Lizenz: deren Grenzen; eine Lizenz ohne Modulliste schaltet alle
* Funktionen frei (unbeschränkter Plan). Ohne gültige Lizenz (kein Schlüssel, ungeprüft, ungültig, abgelaufen oder
* länger als die Offline-Frist nicht geprüft): Testmodus mit TRIAL_LIMITS und ohne LICENSED_FEATURES. */
export async function getEdition(): Promise<Edition> {
// Integrationstests: voller Umfang (wie die abgeschalteten Rate-Limits im Testmodus)
if (config.env === 'test') return { mode: 'licensed', features: [...LICENSED_FEATURES], customerLimit: null, staffLimit: null, reason: 'test' };
const e = await getEntitlement();
if (e.valid) {
const mods = e.modules.filter((m): m is LicensedFeature => (LICENSED_FEATURES as readonly string[]).includes(m));
return { mode: 'licensed', features: e.modules.length ? mods : [...LICENSED_FEATURES], customerLimit: e.customerLimit, staffLimit: e.userLimit, reason: e.plan };
}
const reason = e.source === 'unconfigured' ? 'Kein Lizenzschlüssel hinterlegt' : e.source === 'unchecked' ? 'Lizenz noch nicht geprüft' : (e.message ?? 'Lizenz ungültig oder abgelaufen');
return { mode: 'trial', features: [], customerLimit: TRIAL_LIMITS.customers, staffLimit: TRIAL_LIMITS.staff, reason };
}
export async function hasFeature(f: LicensedFeature): Promise<boolean> { return (await getEdition()).features.includes(f); }
/** Fehlertext für gesperrte Funktionen (einheitlich in API und Worker). */
export const featureRequiresLicense = (f: LicensedFeature) => `${FEATURE_LABEL[f]} ist nur mit Lizenz verfügbar (Einstellungen → Lizenz).`;
/** Modul aus der Lizenz (allgemein, z. B. für künftige Zusatzmodule). */
export async function hasModule(key: string): Promise<boolean> {
const e = await getEntitlement();
return e.valid && (e.modules.length === 0 || e.modules.includes(key));
}
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
/** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung
* aufrufen. GET_LOCK serialisiert gegen gleichzeitige Anfragen auf den letzten freien Platz. Zählt aktive
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. Grenze aus der Edition
* (Lizenz oder Testmodus). */
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
const ed = await getEdition();
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
try {
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
const count = Number(row?.n ?? 0);
if (ed.customerLimit !== null && count >= ed.customerLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.customerLimit} Kunden. Für weitere Kunden bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Kundengrenze der Lizenz erreicht (${count}/${ed.customerLimit}). Bitte Lizenz upgraden.`;
return { allowed: false, reason, count, limit: ed.customerLimit };
}
return { allowed: true, count, limit: ed.customerLimit };
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
}
}
/** Grenze für Personal-Konten (aktive und eingeladene Mitarbeiter). */
export async function assertStaffCapacity(): Promise<CapacityCheck> {
const ed = await getEdition();
const row = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const count = Number(row?.n ?? 0);
if (ed.staffLimit !== null && count >= ed.staffLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.staffLimit} Personal-Konto. Für weitere Mitarbeiter bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Benutzergrenze der Lizenz erreicht (${count}/${ed.staffLimit}).`;
return { allowed: false, reason, count, limit: ed.staffLimit };
}
return { allowed: true, count, limit: ed.staffLimit };
}
export async function setLicenseKey(licenseKey: string): Promise<void> {
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);
}

View file

@ -55,25 +55,38 @@ export function renderTemplateText(tpl: string, vars: Record<string, string>): s
const escapeHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */
const LINK_LABEL: Record<string, string> = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' };
const LINK_LABEL: Record<string, string> = {
inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen',
keepLink: 'Vertrag behalten', cancelLink: 'Vertrag kündigen',
};
/** Zweitrangige Schaltfläche (zurückhaltender gestaltet) statt der primären Akzentfarbe - z. B. für "Kündigen" neben "Behalten". */
const LINK_SECONDARY = new Set(['cancelLink']);
/**
* Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Eine Variable, deren Name auf
* "Link" endet, wird nicht als nackte URL im Text belassen, sondern als Schaltfläche ans Ende gesetzt (der
* Klartext-Teil der Mail behält den Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht).
* Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Jede Variable, deren Name auf
* "Link" endet, wird nicht als nackte URL im Text belassen, sondern als eigene Schaltfläche ans Ende gesetzt
* (mehrere "*Link"-Variablen ergeben mehrere Schaltflächen, z. B. "Behalten"/"Kündigen" nebeneinander). Der
* Klartext-Teil der Mail behält jeden Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht.
*/
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]);
const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars;
const linkKeys = Object.keys(vars).filter((k) => /Link$/.test(k) && vars[k]);
const bodyVars = linkKeys.length ? { ...vars, ...Object.fromEntries(linkKeys.map((k) => [k, ''])) } : vars;
const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m))
.replace(/https?:\/\/[^\s<]+/g, (url) => `<a href="${url}" style="color:#3b6fd8;">${url}</a>`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button
const paragraphs = rendered.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p style="margin:0 0 16px;">${p.replace(/\n/g, '<br>')}</p>`).join('');
const button = linkKey ? `<p style="margin:24px 0 0;"><a href="${escapeHtml(vars[linkKey]!)}" style="display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;">${LINK_LABEL[linkKey] ?? 'Öffnen'}</a></p>` : '';
const buttons = linkKeys.map((k) => {
const secondary = LINK_SECONDARY.has(k);
const style = secondary
? 'display:inline-block;background:#ffffff;color:#3b6fd8;text-decoration:none;padding:11px 23px;border-radius:8px;font-weight:600;border:1px solid #3b6fd8;'
: 'display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;';
return `<a href="${escapeHtml(vars[k]!)}" style="${style}margin-right:12px;">${LINK_LABEL[k] ?? 'Öffnen'}</a>`;
}).join('');
const buttonRow = buttons ? `<p style="margin:24px 0 0;">${buttons}</p>` : '';
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
<table role="presentation" width="100%" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #e3e8f0;border-radius:12px;">
<tr><td style="padding:28px 32px;">
<div style="font-weight:700;font-size:18px;color:#173568;margin-bottom:20px;">Kundencenter</div>
<div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${button}</div>
<div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${buttonRow}</div>
</td></tr></table>
</body></html>`;
}