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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>