Commit graph

58 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
Kundencenter
4976fc529e fix(discord): /ticket auch für Personal nutzbar, klarere Fehlermeldung
Bug gefunden: /ticket prüfte ausschließlich ein verknüpftes
Kundenkonto. Ein Discord-Konto kann aber immer nur mit einem
Kundencenter-Konto verknüpft sein – wer sein Konto (wie in der
Anleitung empfohlen) als Personal verknüpft hat, bekam die
irreführende Meldung "Bitte zuerst dein Konto verknüpfen", obwohl das
Konto sehr wohl verknüpft war (nur eben nicht als Kunde).

Jetzt: /ticket erkennt automatisch, ob das aufrufende Discord-Konto
mit einem Kunden- oder Personal-Konto verknüpft ist.
- Kunde: legt wie bisher ein eigenes Ticket samt Thread an.
- Personal: neue Option "kunde" (Kundennummer oder Name) legt ein
  Ticket für den jeweiligen Kunden an (Status "wartet auf Kunde", wie
  beim Anlegen über die Web-Oberfläche). Kein automatischer Thread,
  da der Kunde dafür selbst Discord verknüpft haben müsste.
- Fehlermeldung bei fehlender Verknüpfung präzisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:17:55 +02:00
Kundencenter
078b1357fb refactor(discord): einheitliche Berechtigung über verknüpfte Konten
Nutzerfrage: warum gibt es neben der Kontoverknüpfung noch eine
separate "Erlaubte Nutzer-IDs"-Liste, und woher weiß der Bot, ob
jemand Kunde oder Personal ist? Antwort: das waren zwei parallele,
verwirrende Mechanismen. /kc-status und /kc-kunde prüften bisher eine
manuell gepflegte Discord-ID-Liste (aus der Zeit vor der OAuth-
Verknüpfung), während /zuweisen und /schliessen bereits das verknüpfte
Personal-Konto (users.discord_user_id, kind='staff') prüften.

Jetzt einheitlich: alle vier Personal-Befehle verlangen ein verknüpftes
Personal-Konto, genau wie /zuweisen und /schliessen. Die Liste entfällt
vollständig (Migration 029, Spalte staff_user_ids entfernt) – wer ein
Personal-Konto im Kundencenter hat und es unter "Mein Konto" mit
Discord verknüpft, darf automatisch alle vier Befehle nutzen, ohne
zusätzliche manuelle Pflege einer ID-Liste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:12:10 +02:00
Kundencenter
8243cdb111 feat(discord): Ticket-Befehle + saubere Rechtetrennung Kunde/Personal
Drei neue Slash-Befehle:
- /ticket (jeder verknüpfte Kunde): erstellt direkt ein neues Ticket
  samt zugehörigem privaten Thread, ganz ohne Web-Besuch.
- /zuweisen und /schliessen (nur Personal, nur innerhalb eines
  Ticket-Threads): weisen das Ticket des aktuellen Threads einer
  per Discord verknüpften Personal-Person zu bzw. schließen es.

Rechtetrennung (Kunden: nur eigenes Ticket erstellen/lesen, Personal:
mehr Rechte):
- Zuweisen/Schließen prüfen serverseitig ein verknüpftes
  Personal-Konto (kind='staff'), unabhängig von der separaten
  staffUserIds-Liste für /kc-status und /kc-kunde.
- Alle vier Personal-Befehle sind zusätzlich per
  setDefaultMemberPermissions(ManageThreads) so eingestellt, dass sie
  Kunden gar nicht erst in der Befehlsliste angezeigt werden (die
  serverseitige Prüfung bleibt trotzdem bestehen, das ist nur die
  UI-Sichtbarkeit).
- Bugfix dabei gefunden: eine Kundenantwort im Thread eines bereits
  geschlossenen Tickets hat es bisher unbemerkt wieder geöffnet (die
  Web-Oberfläche verhindert das schon lange). Jetzt wird das erkannt
  und die Antwort mit einem Hinweis abgelehnt, wie im Web.

Anleitung unter Einstellungen > Discord entsprechend ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:05:38 +02:00
Kundencenter
3371655ca3 feat(mail): Begrüßungsmail mit Zugang, Produkten, Preisen und Discord
Neue Mailvorlage "welcome" (Migration 028), von Personal gezielt über
einen Button auf der Kundenseite ausgelöst ("Begrüßungsmail senden"),
statt automatisch bei der Kundenanlage – sinnvoll ist sie erst, sobald
Produkte zugeordnet sind.

Enthält: Begrüßung, Kundennummer, eine Klartext-Aufstellung der
aktuellen aktiven Produkte mit Preisen (Privatkunden brutto,
Geschäftskunden netto+brutto, wie im Rest der Oberfläche), eine kurze
Anleitung (Ressourcen/Tickets/Rechnungen) und einen Hinweis zur
Discord-Verknüpfung unter Mein Konto. Zugangslink ist je nach Status
entweder ein frischer Einladungslink (noch nicht aktiviert) oder der
normale Login-Link (bereits aktiv).

Außerdem: renderTemplateHtml verlinkt jetzt auch weitere nackte URLs
im Text automatisch (nicht nur die Haupt-Link-Variable), damit z. B.
der Discord-Hinweis in der HTML-Ansicht klickbar ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:50:53 +02:00
Kundencenter
1cd1a8b03a feat(discord): Ticket-Threads mit bidirektionaler Synchronisierung
Zweiter Teil des Discord-Ausbaus. Hat ein Kunde sein Konto verknüpft
(siehe vorheriger Commit), bekommt sein Ticket automatisch einen
privaten Thread im konfigurierten Ticket-Kanal:

- Neuer Job discord.ticket_sync: legt den Thread beim ersten Bedarf
  an (Name "ticket-<Nummer>", Kunde wird als Mitglied hinzugefügt)
  und postet dort jede neue, nicht-interne Nachricht (sowohl Kunden-
  als auch Personal-Antworten). Wird von /tickets (Erstellung durch
  Kunden) und /tickets/:id/messages (Antworten, keine internen
  Notizen) ausgelöst.
- Umgekehrte Richtung: Antwortet der Kunde direkt im Thread, übernimmt
  ein neuer messageCreate-Listener im Bot die Nachricht als
  Ticket-Antwort – nur wenn der Absender über discord_user_id
  eindeutig als Mitglied der Organisation des Tickets verifiziert ist.
  Personal-Nachrichten im Thread werden bewusst nicht übernommen (Web-
  Oberfläche bleibt die Quelle für Personal-Antworten, kein Echo).
- Dafür nötig: privilegierte Message Content Intent (war in der
  Anleitung aus dem vorigen Schritt schon vermerkt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:44:53 +02:00
Kundencenter
fcbd34b0c6 feat(discord): Kunden-Kontoverknüpfung per Discord-OAuth
Erster Teil des größeren Discord-Ausbaus (Konto verknüpfen, Tickets
über Discord sehen/beantworten). Dieser Schritt: Kunden können unter
"Mein Konto" ihr Discord-Konto per OAuth verknüpfen/trennen.

- Migration 027: discord_settings um client_id/client_secret_enc/
  ticket_channel_id erweitert; users um discord_user_id/
  discord_username (unique); discord_oauth_states (kurzlebiger
  CSRF-Schutz für den Redirect-Flow, 10 Min. gültig); neue Tabelle
  ticket_discord_threads für den nächsten Schritt (Ticket-Threads).
- Neue Routen: GET/POST /discord/link, /discord/unlink,
  GET /discord/oauth/start (leitet zu Discord weiter),
  GET /discord/oauth/callback (tauscht Code gegen Discord-Nutzer-ID,
  verknüpft das Konto, verhindert doppelte Verknüpfung).
- Einstellungen > Discord: neue Felder für Client ID/Secret, Anzeige
  der einzutragenden Redirect-URI, aktualisierte Anleitung inkl.
  OAuth-Einrichtung und Hinweis auf die Message Content Intent (für
  den nächsten Schritt: Ticket-Threads).

Noch offen (nächster Schritt): Ticket-Threads je Ticket, bidirektionale
Synchronisierung der Nachrichten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:41:04 +02:00
Kundencenter
c8da9362a7 feat: Discord-Bot über die Oberfläche konfigurierbar
Bisher kam die Discord-Konfiguration (Token, Server-ID, Kanal-ID,
erlaubte Nutzer-IDs) ausschließlich aus Umgebungsvariablen. Jetzt
unter Einstellungen > Discord einstellbar, inkl. Schritt-für-Schritt-
Anleitung zum Einrichten einer Discord-Anwendung/eines Bots (Token
erzeugen, einladen, IDs ermitteln).

- Migration 026: discord_settings (Token verschlüsselt, wie SMTP/IMAP).
- apps/worker/src/discord.ts liest die Einstellungen jetzt aus der DB
  statt aus process.env; syncDiscord() läuft periodisch (60s) und
  verbindet automatisch neu, wenn sich Token/Server/Aktivierung
  geändert haben (Trennung von Verbindungs- und Laufzeitdaten: Kanal-ID
  und erlaubte Nutzer werden bei jeder Nutzung frisch aus der DB
  gelesen, ohne Neuverbindung).
- Neues API-Modul apps/api/src/modules/discord: Einstellungen lesen/
  schreiben, Token- und Testnachricht-Prüfung direkt über die Discord-
  REST-API (unabhängig vom laufenden Bot-Prozess im Worker).
- Rechte discord.read (Admin+) / discord.write (Superadmin), wie beim
  E-Mail-Modul.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:28:24 +02:00
Kundencenter
8616e51adc feat(mail): HTML-Mails mit Link zum Ticket/zur Rechnung als Schaltfläche
Mails wurden bisher nur als Klartext verschickt. sendMail/sendTemplateMail
verschicken jetzt zusätzlich eine einfache, mandantenfarbige HTML-Version
(renderTemplateHtml in @kc/platform/mail): ein Platzhalter, dessen Name auf
"Link" endet, wird darin zu einer Schaltfläche statt einer nackten URL; der
Klartext-Teil behält den Link weiterhin sichtbar (Fallback für Mailprogramme
ohne HTML-Ansicht).

ticket_created/ticket_message bekommen dafür eine neue Variable
{{ticketLink}}, invoice_issued {{invoiceLink}} (Migration 025 – nur
Vorlagen mit noch unverändertem Text werden angepasst, damit von Personal
bereits bearbeitete Texte nicht überschrieben werden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:00:31 +02:00
Kundencenter
c0289317bf feat(tickets): E-Mail-Benachrichtigung beim Antworten sichtbar/steuerbar
Beim Antworten auf ein Kunden-Ticket verschickte das System schon
automatisch (unsichtbar) eine Benachrichtigung an den Kunden. Jetzt
gibt es dafür eine Checkbox "Kunde per E-Mail benachrichtigen"
(Personal, Standard: an) im Antwortformular, und nach dem Absenden
eine Bestätigung ("Antwort gesendet, Kunde wurde per E-Mail
benachrichtigt."), statt dass der Versand unsichtbar im Hintergrund
passiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:50:42 +02:00
Kundencenter
fe18bf0bce feat: IMAP-Posteingang fürs Ticketsystem (Mail-Antworten -> Tickets)
Neues Postfach lässt sich unter Einstellungen > E-Mail > IMAP-Posteingang
einrichten (Host/Port/Verschlüsselung/Zugangsdaten/Ordner, An/Aus,
Verbindungstest). Der Worker ruft es alle 2 Minuten ab (mailbox.ts) und
ordnet eingehende Mails zu:

1. Betreff enthält eine Ticketnummer (T-123) UND Absender gehört
   nachweislich zum Kunden dieses Tickets -> wird als Kundennachricht
   angehängt (Status pending_staff, Discord-Benachrichtigung).
2. Sonst, wenn der Absender ein bekannter aktiver Kunde ist -> neues
   Ticket wird automatisch unter seinem Kundenkonto angelegt.
3. Sonst (unbekannter/nicht verifizierbarer Absender) -> landet im
   neuen Posteingang (/admin/inbox, UI unter /admin/posteingang) zur
   manuellen Zuordnung: Personal wählt einen Kunden (legt Ticket an),
   hängt die Mail an ein bestehendes Ticket, oder ignoriert sie.

Beim ersten Aktivieren wird kein Mailbox-Bestand verarbeitet, nur ab
dann neu Eingehendes (last_uid wird auf den aktuellen Stand gesetzt).

Migration 024: imap_settings (Verbindung, verschlüsseltes Passwort,
last_uid/last_error), inbox_messages (Warteschlange). Rechte:
email.read/write für die IMAP-Verbindung (wie SMTP), tickets.read/write
für den Posteingang (Zuordnung ist eine Ticket-Aktion, daher im
Tickets-Modul statt eines eigenen Moduls, um keine Modulgrenzen zu
verletzen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:35:58 +02:00
Kundencenter
7d2b2947f4 feat: separate Absenderprofile für Kundencenter/Störungsticket/Rechnungen
Bisher gab es nur einen globalen SMTP-Absender für alle Mailvorlagen.
Jetzt kann je Bereich ein eigener Absendername/-adresse hinterlegt
werden (z. B. support@… für Tickets, rechnung@… für Rechnungen);
leer gelassene Felder fallen weiter auf den globalen SMTP-Absender
zurück. Jede Vorlage ist fest einem Bereich zugeordnet (Migration
023: mail_templates.category, neue Tabelle mail_sender_profiles mit
den drei Zeilen kundencenter/stoerungsticket/rechnungen).

Außerdem: der Testmail-Versand (SMTP-Verbindung wie auch je Vorlage)
zeigt jetzt den konkreten Fehler aus dem Mail-Protokoll an (z. B.
"535 5.7.8 authentication failed") statt nur "Fehlgeschlagen".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:16:42 +02:00
Kundencenter
bbece6ca5f fix(web): SMTP-Speichern zeigte fälschlich "Server nicht erreichbar"
Das Formular griff nach dem await auf e.currentTarget zu, um das
Passwortfeld zu leeren. React setzt currentTarget nach der Event-
Verarbeitung auf null, wodurch eine normale (keine ApiError-)Exception
flog und die generische Netzwerkfehler-Meldung angezeigt wurde –
obwohl das Speichern selbst serverseitig erfolgreich war. Die
Formularreferenz wird jetzt vor dem await festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:05:11 +02:00
Kundencenter
a68b4cf6ec feat: SMTP-Stack mit konfigurierbaren Mailvorlagen
Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung,
Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus
Umgebungsvariablen. Jetzt:

- SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host,
  Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert,
  Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach
  @kc/platform/mail, damit API und Worker sie gemeinsam nutzen.
- Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition"
  (Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind
  editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand
  von Beispieldaten. Unbekannte Platzhalter werden beim Speichern
  abgelehnt.
- Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde),
  Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung
  ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel-
  Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese
  laufen über die Jobqueue (mail.template), analog zu discord.notify.
- Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter
  Einstellungen > E-Mail.
- Migration 022, Rechte email.read (Admin+) / email.write (Superadmin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:53:29 +02:00
Kundencenter
b112647ff1 feat(web): Designsystem v3 "KeyControl" + gruppierte Navigation
Löst das bisherige "Calm Cloud"-Design nach Nutzerfeedback ab: dunkle
Navy-Sidebar (#173568) als dauerhaft sichtbare, vertrauenswürdige
Navigation, klares Blau (#3B6FD8) als Primärfarbe, hellgrauer
Arbeitsbereich (#F4F6FA). Sidebar breiter mit mehr Abstand, größere
Schriftgrößen für kleine/gedämpfte Texte, größere max. Inhaltsbreite.

Navigation in aufklappbare Gruppen sortiert (Produkte / Abrechnung /
Support / Verwaltung) statt einer langen flachen Liste. Kopfbereich
zeigt bei Kunden zusätzlich den Organisationsnamen.

Ressourcenübersicht zeigt Kunden ihre Produkte jetzt als Kartenraster
statt als spärliche Tabelle (Personal behält die tabellarische Ansicht
für die höhere Datendichte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:43:50 +02:00
Kundencenter
2f8566307b fix(worker): handle invoice.issued event in discord.notify
The invoices module started enqueueing invoice.issued notifications but
the worker's discord.notify handler had no case for it, causing the job
to exhaust retries and land in needs_review (surfaced on the dashboard
as "Aufträge mit Fehlern / Prüfbedarf").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:34:59 +02:00
Kundencenter
69fbad9090 Design: 'Calm Cloud' Farbwelt (Dunkelblau/Türkis, GitHub-Dark), untere Navigation auf Mobilgeräten 2026-09-28 11:23:53 +02:00
Kundencenter
2d168ff9cc Design: modernisiertes Erscheinungsbild (Schatten statt schwerer Rahmen, Inter-Schrift, Tag/Nacht/System-Umschalter) 2026-09-28 11:09:58 +02:00
Kundencenter
96df0c334a Rechnungen: Rabatt je Position 2026-09-28 11:01:19 +02:00
Kundencenter
e7e5f31ebe Rechnungen: Absender-/Empfängerdaten beim Ausstellen einfrieren, Statuswechsel atomar (behebt Codex-Fund #95) 2026-09-28 10:29:00 +02:00
Kundencenter
6fec6277f5 KeyHelp: Panel-Passwort neu vergeben und verschlüsselt hinterlegen (verblurrt wie Lizenzschlüssel) 2026-09-27 21:35:15 +02:00
Kundencenter
3a2864ee76 Rechnungen: Überblick über fällige/überfällige Rechnungen und Verträge 2026-09-27 21:19:48 +02:00
Kundencenter
5efe37ecc0 Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate) 2026-09-27 18:35:34 +02:00
Kundencenter
e0fd57532d Rechnungen: Positionen auch aus der Domain-Aufstellung übernehmen (Verkaufspreis) 2026-09-27 18:23:31 +02:00