From 51089b22496b7d6fe07af127dc0c7aa3486974c4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 29 Sep 2026 08:52:05 +0200 Subject: [PATCH 01/28] Guard audit writes when advisory lock is unavailable --- packages/platform/src/audit.ts | 3 ++- packages/platform/test/audit-lock.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/platform/test/audit-lock.test.ts diff --git a/packages/platform/src/audit.ts b/packages/platform/src/audit.ts index fde97a3..4b4ca90 100644 --- a/packages/platform/src/audit.ts +++ b/packages/platform/src/audit.ts @@ -36,7 +36,8 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise const c = conn ?? (await pool.getConnection()); try { if (own) await c.beginTransaction(); - await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']); + const [locks] = await c.query('SELECT GET_LOCK(?, 10) AS acquired', ['kc_audit_chain']); + if (Number(locks[0]?.acquired) !== 1) throw new Error('Audit lock unavailable'); 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(); diff --git a/packages/platform/test/audit-lock.test.ts b/packages/platform/test/audit-lock.test.ts new file mode 100644 index 0000000..0cbc322 --- /dev/null +++ b/packages/platform/test/audit-lock.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; + +const connection = vi.hoisted(() => ({ + beginTransaction: vi.fn(), + query: vi.fn(), + execute: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + release: vi.fn(), +})); + +vi.mock('../src/db.js', () => ({ + pool: { getConnection: vi.fn(async () => connection) }, + one: vi.fn(), + query: vi.fn(), +})); + +import { audit } from '../src/audit.js'; + +describe('audit advisory lock', () => { + it.each([0, null])('does not append an event when GET_LOCK returns %s', async (acquired) => { + vi.clearAllMocks(); + connection.query.mockResolvedValueOnce([[{ acquired }]]).mockResolvedValueOnce([[{ released: null }]]); + + await expect(audit({ actorType: 'system', action: 'test.lock' })).rejects.toThrow('Audit lock unavailable'); + expect(connection.execute).not.toHaveBeenCalled(); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalledOnce(); + expect(connection.release).toHaveBeenCalledOnce(); + }); +}); From acf8751f9f50b00d260be82ed8c19f5da8178850 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 09:56:46 +0200 Subject: [PATCH 02/28] =?UTF-8?q?fix(audit):=20GET=5FLOCK-Ergebnis=20pr?= =?UTF-8?q?=C3=BCfen,=20connector/ip=20in=20Hash-Kette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- migrations/030_audit_hash_v2.sql | 6 ++++++ packages/platform/src/audit.ts | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 migrations/030_audit_hash_v2.sql diff --git a/migrations/030_audit_hash_v2.sql b/migrations/030_audit_hash_v2.sql new file mode 100644 index 0000000..2425be2 --- /dev/null +++ b/migrations/030_audit_hash_v2.sql @@ -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; diff --git a/packages/platform/src/audit.ts b/packages/platform/src/audit.ts index fde97a3..aa98f86 100644 --- a/packages/platform/src/audit.ts +++ b/packages/platform/src/audit.ts @@ -36,22 +36,24 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise 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) { @@ -65,12 +67,14 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise /** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */ export async function verifyAuditChain(q: (sql: string) => Promise = (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); 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 = { 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'); From 8cf840444022a18f5f23e40b400bac48d6c8e093 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 10:44:24 +0200 Subject: [PATCH 03/28] =?UTF-8?q?fix(backup):=20Ticket-Anh=C3=A4nge=20in?= =?UTF-8?q?=20Sicherung=20und=20Wiederherstellungstest=20einschlie=C3=9Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/ops/backup.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/api/src/ops/backup.ts b/apps/api/src/ops/backup.ts index ccb6eb1..c7087a6 100644 --- a/apps/api/src/ops/backup.ts +++ b/apps/api/src/ops/backup.ts @@ -3,12 +3,13 @@ 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'; @@ -72,6 +73,21 @@ async function tableCounts(q: (sql: string) => Promise): Promise { + 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> { const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record }[] = []; const built: Remote[] = []; @@ -87,8 +103,12 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise 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')]); @@ -172,6 +192,12 @@ export async function runRestoreTest(c: BackupConfig, file?: string): Promise Date: Tue, 29 Sep 2026 11:05:56 +0200 Subject: [PATCH 04/28] =?UTF-8?q?feat(rechnungen):=20Export=20f=C3=BCr=20d?= =?UTF-8?q?en=20Steuerberater=20(CSV,=20PDF-ZIP,=20DATEV-Buchungsstapel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/invoices/index.ts | 260 +++++++++++++++++- .../app/(app)/einstellungen/firma/page.tsx | 29 +- .../src/app/(app)/rechnungen/export/page.tsx | 44 +++ apps/web/src/app/(app)/rechnungen/page.tsx | 2 +- migrations/031_datev_export.sql | 13 + 5 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/app/(app)/rechnungen/export/page.tsx create mode 100644 migrations/031_datev_export.sql diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 3195c26..9adb18f 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -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'; @@ -37,6 +41,21 @@ async function settings(): Promise { } 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 { + 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 +76,107 @@ const notify = (event: string, extra: Record, key: string, corr const mailTo = (to: string, template: string, vars: Record, 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): { 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('.', ','); +const csvCell = (s: string): string => (/[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); +const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : ''); +const STATUS_LABEL: Record = { 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) => `"${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): 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 = { + 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): 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 { + 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 { + 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 +186,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 +198,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 = { 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 = { + 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)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record)[k]); } if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); } @@ -285,21 +418,118 @@ 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(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(); + 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'); + 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')); + }); }, }; diff --git a/apps/web/src/app/(app)/einstellungen/firma/page.tsx b/apps/web/src/app/(app)/einstellungen/firma/page.tsx index e8024f9..47e5def 100644 --- a/apps/web/src/app/(app)/einstellungen/firma/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/firma/page.tsx @@ -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) { 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() { +

DATEV-Export

+

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

+ {!s.datev.complete && DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer fehlen noch.} +
+ + + + + + + + +
+

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

+
+ + + +
{w ? :

Nur Superadministratoren können diese Angaben ändern.

} ); diff --git a/apps/web/src/app/(app)/rechnungen/export/page.tsx b/apps/web/src/app/(app)/rechnungen/export/page.tsx new file mode 100644 index 0000000..ae45c85 --- /dev/null +++ b/apps/web/src/app/(app)/rechnungen/export/page.tsx @@ -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 (<> +

Export für den Steuerberater

← Rechnungen
+

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.

+
+
+ setFrom(e.target.value)} /> + setTo(e.target.value)} /> + + + +
+ +

+ 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 Einstellungen → Firma; fehlen sie, öffnet der Link statt der Datei eine Fehlermeldung mit den fehlenden Angaben. +

+
+ ); +} diff --git a/apps/web/src/app/(app)/rechnungen/page.tsx b/apps/web/src/app/(app)/rechnungen/page.tsx index 32da919..9a74a3c 100644 --- a/apps/web/src/app/(app)/rechnungen/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/page.tsx @@ -4,5 +4,5 @@ import { useSession } from '@/lib/session'; import { InvoiceList } from '@/components/InvoiceList'; export default function Rechnungen() { const { can } = useSession(); - return (<>

Rechnungen

{can('invoices.read') && Fällig}
); + return (<>

Rechnungen

{can('invoices.read') &&
Export (Steuerberater)Fällig
}
); } diff --git a/migrations/031_datev_export.sql b/migrations/031_datev_export.sql new file mode 100644 index 0000000..f86db12 --- /dev/null +++ b/migrations/031_datev_export.sql @@ -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) From 1bda3ac1aab9c953708896be899bf76b00721a7d Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:25:27 +0200 Subject: [PATCH 05/28] fix(invoices): CSV-Formel-Injection im Export verhindern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/invoices/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 9adb18f..7d634a5 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -93,7 +93,10 @@ function exportFilter(q: z.infer): { sql: string; params: un return { sql: conds.join(' AND '), params }; } const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); -const csvCell = (s: string): string => (/[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); +// 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 = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' }; @@ -123,7 +126,7 @@ const DATEV_BOOKING_HEADER = [ 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) => `"${s.replace(/"/g, '""')}"`; +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): string { From 881f19ed05e65c2f000bcce679feecea79c5697f Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:32:26 +0200 Subject: [PATCH 06/28] fix(cli): Passwort nie mehr als Klartext-Argument, neuer reset-password-Befehl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/cli/index.ts | 54 +++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 0971736..6798408 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -6,15 +6,59 @@ import { audit } from '../core/audit.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 { + 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 { + 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='); 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 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) @@ -50,7 +94,7 @@ if (cmd === 'create-superadmin') { 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 { - console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup'); + console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup'); process.exit(2); } await pool.end(); From b64cec21a4c76c7bf7e358e06adcb86384a61951 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:36:52 +0200 Subject: [PATCH 07/28] =?UTF-8?q?fix(cli):=20Migrationen=20mit=20Pr=C3=BCf?= =?UTF-8?q?summe=20und=20Sperre=20gegen=20Parallelausf=C3=BChrung=20h?= =?UTF-8?q?=C3=A4rten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/cli/migrate.ts | 51 +++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/api/src/cli/migrate.ts b/apps/api/src/cli/migrate.ts index 59addb4..1d07980 100644 --- a/apps/api/src/cli/migrate.ts +++ b/apps/api/src/cli/migrate.ts @@ -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 { - 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)); - for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { - if (done.has(f)) 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); - for (const st of stmts) await c.query(st); - await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); - console.log('migriert:', f); - } finally { c.release(); } + 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()) { + 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 = 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, 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]}`) { await migrate(); await pool.end(); } +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(); +} From a9893b6d3274293a0a30e638ac0b524a94ac1ba3 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:41:43 +0200 Subject: [PATCH 08/28] fix(worker): Lease/Heartbeat statt starrer 5-Minuten-Grenze bei Jobwiederaufnahme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/worker/src/jobs.ts | 38 ++++++++++++++++++++++++++--------- migrations/032_jobs_lease.sql | 6 ++++++ 2 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 migrations/032_jobs_lease.sql diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 2d0d8ab..c542d9a 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -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; /** Job-Handler nach Typ. Neue Module registrieren hier ihre Aufträge. */ @@ -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 { 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 { // 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`); } diff --git a/migrations/032_jobs_lease.sql b/migrations/032_jobs_lease.sql new file mode 100644 index 0000000..0b2aa49 --- /dev/null +++ b/migrations/032_jobs_lease.sql @@ -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 From 5ec8a526b3d68f566698359f28ec3dfa952138b5 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:49:26 +0200 Subject: [PATCH 09/28] =?UTF-8?q?fix(audit):=20audit=5Fevents=20auf=20DB-E?= =?UTF-8?q?bene=20unver=C3=A4nderlich=20machen=20(append-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/test/core.test.ts | 17 +++++++++++++---- migrations/033_audit_immutable.sql | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 migrations/033_audit_immutable.sql diff --git a/apps/api/test/core.test.ts b/apps/api/test/core.test.ts index d1f493f..bb846dc 100644 --- a/apps/api/test/core.test.ts +++ b/apps/api/test/core.test.ts @@ -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/); }); }); diff --git a/migrations/033_audit_immutable.sql b/migrations/033_audit_immutable.sql new file mode 100644 index 0000000..7fa3365 --- /dev/null +++ b/migrations/033_audit_immutable.sql @@ -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'; From 3240e6df79569042054f42655fb7bd2697ccbe24 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 12:01:00 +0200 Subject: [PATCH 10/28] =?UTF-8?q?feat(audit):=20Aufbewahrungs-Policy=20mit?= =?UTF-8?q?=20Checkpoint-L=C3=B6schung=20und=20CSV-Export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/cli/index.ts | 9 ++- apps/api/src/modules/audit/index.ts | 70 +++++++++++++++++++-- apps/web/src/app/(app)/admin/audit/page.tsx | 44 ++++++++++++- apps/worker/src/index.ts | 13 ++++ migrations/034_audit_retention.sql | 23 +++++++ packages/platform/src/audit.ts | 47 +++++++++++++- 6 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 migrations/034_audit_retention.sql diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 6798408..1b6e203 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -93,8 +93,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, reset-password, 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(); diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts index 571015d..cac1e3c 100644 --- a/apps/api/src/modules/audit/index.ts +++ b/apps/api/src/modules/audit/index.ts @@ -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'); + }); }, }; diff --git a/apps/web/src/app/(app)/admin/audit/page.tsx b/apps/web/src/app/(app)/admin/audit/page.tsx index f7ef5aa..f85e9e2 100644 --- a/apps/web/src/app/(app)/admin/audit/page.tsx +++ b/apps/web/src/app/(app)/admin/audit/page.tsx @@ -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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/audit/settings').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function save(e: FormEvent) { + 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 (
+

Aufbewahrung & Export

+

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

+ {err && {err}}{ok && {ok}} +
+ + {w && } + {w && } + Export (CSV) +
+ {s.lastPurge + ?

Letzte Aufräumung: {fmt(s.lastPurge.at)}, {s.lastPurge.count} Einträge gelöscht (Frist damals {s.lastPurge.retentionDays} Tage).

+ :

Noch keine Aufräumung durchgeführt.

} +
); +} + export default function Audit() { const [list, setList] = useState(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null); const load = useCallback(() => api('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]); @@ -12,6 +51,7 @@ export default function Audit() {

Audit-Protokoll

{err && {err}} {chain && (chain.brokenAt === null ? Hash-Kette intakt ({chain.checked} Einträge geprüft). : Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.)} +
setAction(e.target.value)} placeholder="z. B. auth. oder customer." />
{list === null ?

Wird geladen …

: list.length === 0 ? :
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1e2d0c5..395c6b8 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -42,6 +42,19 @@ const checkBackup = async () => { }; setInterval(() => void checkBackup(), 3600_000); +// 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}`)); diff --git a/migrations/034_audit_retention.sql b/migrations/034_audit_retention.sql new file mode 100644 index 0000000..59c3f70 --- /dev/null +++ b/migrations/034_audit_retention.sql @@ -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 +); diff --git a/packages/platform/src/audit.ts b/packages/platform/src/audit.ts index aa98f86..42a213c 100644 --- a/packages/platform/src/audit.ts +++ b/packages/platform/src/audit.ts @@ -65,10 +65,16 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise } } -/** 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 = (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, 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) { // Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein. const body: Record = { @@ -83,3 +89,42 @@ export async function verifyAuditChain(q: (sql: string) => Promise = (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 { + 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(); } +} From d03308822307ad4bca411ba40c662e3800a3472a Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 10:48:48 +0200 Subject: [PATCH 11/28] feat(license): Kundencenter-Lizenzierung gegen licensing.flessinglabs.com (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/core/license.ts | 1 + apps/api/src/modules/customers/index.ts | 9 + apps/api/src/modules/index.ts | 3 +- apps/api/src/modules/license/index.ts | 47 +++++ .../src/app/(app)/einstellungen/layout.tsx | 1 + .../app/(app)/einstellungen/lizenz/page.tsx | 64 +++++++ apps/worker/src/index.ts | 11 ++ migrations/035_license_state.sql | 37 ++++ packages/platform/src/config.ts | 5 + packages/platform/src/license.ts | 163 ++++++++++++++++++ 10 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/core/license.ts create mode 100644 apps/api/src/modules/license/index.ts create mode 100644 apps/web/src/app/(app)/einstellungen/lizenz/page.tsx create mode 100644 migrations/035_license_state.sql create mode 100644 packages/platform/src/license.ts diff --git a/apps/api/src/core/license.ts b/apps/api/src/core/license.ts new file mode 100644 index 0000000..be2d85f --- /dev/null +++ b/apps/api/src/core/license.ts @@ -0,0 +1 @@ +export * from '@kc/platform/license'; diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index e4db92e..b953353 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -5,6 +5,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 { 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'; @@ -84,6 +85,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; @@ -145,6 +148,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 +228,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 */ } diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 06db281..fb61559 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -13,6 +13,7 @@ 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'; /** 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]; diff --git a/apps/api/src/modules/license/index.ts b/apps/api/src/modules/license/index.ts new file mode 100644 index 0000000..b2d13c1 --- /dev/null +++ b/apps/api/src/modules/license/index.ts @@ -0,0 +1,47 @@ +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, getEntitlement, setLicenseKey, 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'); + return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null }; + }); + + 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; + } + }); + }, +}; diff --git a/apps/web/src/app/(app)/einstellungen/layout.tsx b/apps/web/src/app/(app)/einstellungen/layout.tsx index d12f32c..e81c34e 100644 --- a/apps/web/src/app/(app)/einstellungen/layout.tsx +++ b/apps/web/src/app/(app)/einstellungen/layout.tsx @@ -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 Keine Berechtigung.; return (<> diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx new file mode 100644 index 0000000..d6874c5 --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -0,0 +1,64 @@ +'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; +} +const SOURCE_LABEL: Record = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; +const MODULE_LABEL: Record = { 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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function saveKey(e: FormEvent) { + 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 ? {err} :

Wird geladen …

; + return (<> +

Lizenz

+

Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).

+ {err && {err}}{ok && {ok}} + {!s.configured && Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.} + {s.configured && s.source === 'unchecked' && Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.} + {s.configured && s.source !== 'unchecked' && !s.valid && Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.} + +
+

Status

+
+
Plan
{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}
+
Module
{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}
+
Kundengrenze
{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}
+
Nutzer-/Slotlimit
{s.userLimit === null ? 'unbegrenzt' : s.userLimit}
+
Gültig bis
{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}
+
Zuletzt geprüft
{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})
+
+ {w && } +
+ + {w &&
+

Lizenzschlüssel

+ + + } +

Installations-ID: {s.instanceId} (bleibt stabil, zählt sonst als neues Gerät).

+ ); +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 395c6b8..2aa3820 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -42,6 +42,17 @@ 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(); + // 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. diff --git a/migrations/035_license_state.sql b/migrations/035_license_state.sql new file mode 100644 index 0000000..1f06b6f --- /dev/null +++ b/migrations/035_license_state.sql @@ -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) +); diff --git a/packages/platform/src/config.ts b/packages/platform/src/config.ts index 935f186..ca54f85 100644 --- a/packages/platform/src/config.ts +++ b/packages/platform/src/config.ts @@ -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), diff --git a/packages/platform/src/license.ts b/packages/platform/src/license.ts new file mode 100644 index 0000000..a52ffc7 --- /dev/null +++ b/packages/platform/src/license.ts @@ -0,0 +1,163 @@ +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.., 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 } { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false }; + const [, payloadB64, sigB64] = parts; + let payload: Record; + 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 { + 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 { + 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 { + 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 { + 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, + }; +} +/** Wie assertCustomerCapacity: solange Lizenzierung nicht konfiguriert/geprüft ist, nichts einschränken. */ +export async function hasModule(key: string): Promise { + const e = await getEntitlement(); + if (e.source === 'unconfigured' || e.source === 'unchecked') return true; + return e.valid && 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. + * + * Solange die Lizenzierung gar nicht konfiguriert ist (kein LICENSE_PROGRAM_KEY/-schlüssel) oder noch nie + * erfolgreich geprüft wurde, wird NICHT durchgesetzt - sonst wäre jede frische Installation (und jede Test- + * umgebung) ab dem ersten Kunden blockiert. Erst eine tatsächlich geprüfte, ungültige/überschrittene Lizenz + * sperrt neue Kunden. */ +export async function assertCustomerCapacity(c: PoolConnection): Promise { + const ent = await getEntitlement(); + if (ent.source === 'unconfigured' || ent.source === 'unchecked') return { allowed: true, count: 0, limit: null }; + if (!ent.valid) return { allowed: false, reason: ent.message ?? 'Keine gültige Lizenz', count: 0, limit: null }; + 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 (ent.customerLimit !== null && count >= ent.customerLimit) return { allowed: false, reason: `Kundengrenze erreicht (${count}/${ent.customerLimit}). Bitte Lizenz upgraden.`, count, limit: ent.customerLimit }; + return { allowed: true, count, limit: ent.customerLimit }; + } finally { + await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']); + } +} + +export async function setLicenseKey(licenseKey: string): Promise { + await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]); +} From c52996b559a613f1a6339bab46432fa196466add Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 10:50:49 +0200 Subject: [PATCH 12/28] =?UTF-8?q?feat(license):=20Kundenz=C3=A4hler=20und?= =?UTF-8?q?=20Upgrade-Hinweise=20in=20der=20Lizenz=C3=BCbersicht=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/license/index.ts | 3 ++- apps/web/src/app/(app)/einstellungen/lizenz/page.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/license/index.ts b/apps/api/src/modules/license/index.ts index b2d13c1..06a669a 100644 --- a/apps/api/src/modules/license/index.ts +++ b/apps/api/src/modules/license/index.ts @@ -19,7 +19,8 @@ export const licenseModule: KcModule = { 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'); - return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null }; + const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')"); + return { ...ent, 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) => { diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx index d6874c5..16e186d 100644 --- a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -8,6 +8,7 @@ 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; } const SOURCE_LABEL: Record = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; const MODULE_LABEL: Record = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' }; @@ -40,13 +41,16 @@ export default function LizenzPage() { {!s.configured && Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.} {s.configured && s.source === 'unchecked' && Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.} {s.configured && s.source !== 'unchecked' && !s.valid && Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.} + {s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.} + {s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.} + {s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.}

Status

Plan
{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}
Module
{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}
-
Kundengrenze
{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}
+
Kunden
{s.customerCount}{s.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.customerLimit}`}
Nutzer-/Slotlimit
{s.userLimit === null ? 'unbegrenzt' : s.userLimit}
Gültig bis
{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}
Zuletzt geprüft
{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})
From 2ed9697ca6d3d673d10b87b13936ad1e388f8b8d Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 10:59:25 +0200 Subject: [PATCH 13/28] feat(customers): Impersonation ("als Kunde ansehen") mit Kennzeichnung und Audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/core/auth.ts | 34 ++++++++++++++++--- apps/api/src/modules/customers/index.ts | 19 ++++++++++- apps/api/src/modules/identity/index.ts | 20 +++++++++-- .../src/app/(app)/admin/kunden/[id]/page.tsx | 20 +++++++++-- apps/web/src/app/(app)/layout.tsx | 7 ++++ apps/web/src/lib/session.tsx | 2 ++ migrations/036_impersonation.sql | 7 ++++ 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 migrations/036_impersonation.sql diff --git a/apps/api/src/core/auth.ts b/apps/api/src/core/auth.ts index 5e87b9a..c668ef6 100644 --- a/apps/api/src/core/auth.ts +++ b/apps/api/src/core/auth.ts @@ -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 { 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 { [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, }; } diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index b953353..89ace95 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -3,7 +3,7 @@ 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'; @@ -60,6 +60,9 @@ async function nextCustomerNumber(c: Parameters[2]): Promise 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) => { @@ -108,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'); diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts index 5a91382..98ff27f 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -93,16 +93,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 - ? 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]) - : []; + // 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 }); diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx index 1100ffd..d02364a 100644 --- a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -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,10 @@ 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(null); const [orders, setOrders] = useState(null); const [ress, setRess] = useState(null); const [o, setO] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null); + const [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false); const load = useCallback(() => api('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); api('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]); if (!o) return msg ? {msg.t} :

Wird geladen …

; @@ -37,6 +38,13 @@ export default function Kunde() { async function setStatus(status: string) { try { setO(await api('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } + async function impersonate(e: FormEvent) { + 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); } + } const [welcomeBusy, setWelcomeBusy] = useState(false); async function sendWelcome() { setWelcomeBusy(true); @@ -45,9 +53,15 @@ export default function Kunde() { } return (<>

← Alle Kunden

-

{o.name}

+

{o.name}

{can('customers.impersonate') && }

{o.customerNumber} · {o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}

{msg && {msg.t}} + {impersonateOpen &&
+

Als Kunde ansehen

+

Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.

+ +
+ }

Stammdaten & Rechnungsanschrift

{o.customerType === 'business' && } diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5ba1d19..8a59b43 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -20,6 +20,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{title}
{items}
); async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } + 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('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 = ( @@ -44,6 +45,12 @@ export default function AppLayout({ children }: { children: ReactNode }) { ); const bottomLink = (href: string, label: string) => {label}; return (
+ {me.impersonating && ( +
+ 👁 Ansicht als Kunde: {me.impersonating.orgName} ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason} + +
+ )}
Kundencenter
{nav}
{children}
{!enrollNeeded && ); const bottomLink = (href: string, label: string) => {label}; - return (
+ return (<> {me.impersonating && ( -
+
👁 Ansicht als Kunde: {me.impersonating.orgName} ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason}
)} +
Kundencenter
{nav}
{children}
{!enrollNeeded && } -
); +
+ ); } From dad95282957a3761d51435f7ba954709bfbb7ffb Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 11:51:19 +0200 Subject: [PATCH 16/28] Add contract renewal reminder emails (keep/cancel decision links) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/orders/index.ts | 15 +++ .../web/src/app/vertrag-entscheidung/page.tsx | 49 ++++++++ apps/worker/src/index.ts | 10 ++ migrations/037_contract_renewal_reminder.sql | 27 +++++ packages/platform/src/contractRenewal.ts | 111 ++++++++++++++++++ packages/platform/src/mail.ts | 29 +++-- 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/vertrag-entscheidung/page.tsx create mode 100644 migrations/037_contract_renewal_reminder.sql create mode 100644 packages/platform/src/contractRenewal.ts diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 6682f56..89f045e 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -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'; @@ -217,5 +219,18 @@ 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' }; }); + + // ---- 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; } + }); }, }; diff --git a/apps/web/src/app/vertrag-entscheidung/page.tsx b/apps/web/src/app/vertrag-entscheidung/page.tsx new file mode 100644 index 0000000..bd48014 --- /dev/null +++ b/apps/web/src/app/vertrag-entscheidung/page.tsx @@ -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(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('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 (
+

Vertragsentscheidung

+ {err && {err}} + {done ? ( + + {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.'} + + ) : info ? (<> +

+ Vertrag {info.contractNumber} ({info.productName}) für {info.orgName} läuft am{' '} + {new Date(info.termEnd).toLocaleDateString('de-DE')} aus. Möchten Sie ihn behalten oder kündigen? +

+
+ + +
+

„Behalten" verlängert den Vertrag automatisch um eine weitere Laufzeit. „Kündigen" leitet die Abmeldung ein.

+ ) : !err &&

Wird geladen …

} +
); +} +export default function Page() { return ; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2aa3820..1869916 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -53,6 +53,16 @@ const checkLicense = async () => { }; 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. diff --git a/migrations/037_contract_renewal_reminder.sql b/migrations/037_contract_renewal_reminder.sql new file mode 100644 index 0000000..31df695 --- /dev/null +++ b/migrations/037_contract_renewal_reminder.sql @@ -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'); diff --git a/packages/platform/src/contractRenewal.ts b/packages/platform/src/contractRenewal.ts new file mode 100644 index 0000000..4c13d26 --- /dev/null +++ b/packages/platform/src/contractRenewal.ts @@ -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 { + 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 { + 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 }; + }); +} diff --git a/packages/platform/src/mail.ts b/packages/platform/src/mail.ts index 583da36..20df951 100644 --- a/packages/platform/src/mail.ts +++ b/packages/platform/src/mail.ts @@ -55,25 +55,38 @@ export function renderTemplateText(tpl: string, vars: Record): s const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */ -const LINK_LABEL: Record = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' }; +const LINK_LABEL: Record = { + 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 { - 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) => `${url}`); // 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.replace(/\n/g, '
')}

`).join(''); - const button = linkKey ? `

${LINK_LABEL[linkKey] ?? 'Öffnen'}

` : ''; + 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 `${LINK_LABEL[k] ?? 'Öffnen'}`; + }).join(''); + const buttonRow = buttons ? `

${buttons}

` : ''; return `
ZeitAktionAkteurObjektErgebnis
Kundencenter
-
${paragraphs}${button}
+
${paragraphs}${buttonRow}
`; } From 1178e9edfe969b41d750cfff9ba0c29bd93508a9 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 11:54:46 +0200 Subject: [PATCH 17/28] 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 --- apps/api/src/modules/resources/index.ts | 6 +++++- apps/web/src/app/(app)/ressourcen/[id]/page.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/api/src/modules/resources/index.ts b/apps/api/src/modules/resources/index.ts index 893eb3a..52fc74e 100644 --- a/apps/api/src/modules/resources/index.ts +++ b/apps/api/src/modules/resources/index.ts @@ -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) => { diff --git a/apps/web/src/app/(app)/ressourcen/[id]/page.tsx b/apps/web/src/app/(app)/ressourcen/[id]/page.tsx index 08b24f8..de31e86 100644 --- a/apps/web/src/app/(app)/ressourcen/[id]/page.tsx +++ b/apps/web/src/app/(app)/ressourcen/[id]/page.tsx @@ -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; usage: Record; details: Record; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] } + canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record; usage: Record; details: Record; 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 = { open: 'noch nicht bestellt', ordered: 'beim Registrar bestellt', external: 'extern verwaltet' }; const LIMIT_LABEL: Record = { 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() {
Gerät
{d.details.activation.hardwareIdMasked}
: d.type === 'license' ?
Aktivierung
Noch nicht aktiviert
: null}

Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}

+ {d.domains.length > 0 &&

Domains

+
{staff && } + {d.domains.map((x) => + + {staff && })}
DomainLaufzeitStatusPreis (brutto)
{x.domain}{x.termMonths ? `${x.termMonths} Monate` : '–'}{staff ? PROCUREMENT[x.procurement ?? ''] ?? x.procurement : x.registered ? 'Registriert' : 'In Bearbeitung'}{x.sellGrossCents != null ? eur(x.sellGrossCents) : '–'}
+ {staff &&

Zur Domain-Aufstellung

}
} {d.type === 'hosting_account' && } {(d.hasChildren || d.canLogin) && } {d.canReveal &&

Lizenzschlüssel

} From d00f1a4cec8b0e4533f10454fc6be140fa3db381 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 11:57:20 +0200 Subject: [PATCH 18/28] 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 --- apps/api/src/modules/domains/index.ts | 6 +++--- apps/web/src/components/DomainRecords.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api/src/modules/domains/index.ts b/apps/api/src/modules/domains/index.ts index 1a16cd5..5783b57 100644 --- a/apps/api/src/modules/domains/index.ts +++ b/apps/api/src/modules/domains/index.ts @@ -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]); } diff --git a/apps/web/src/components/DomainRecords.tsx b/apps/web/src/components/DomainRecords.tsx index 0006580..6badda3 100644 --- a/apps/web/src/components/DomainRecords.tsx +++ b/apps/web/src/components/DomainRecords.tsx @@ -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) => ({r.domain}{r.termMonths &&
{r.termMonths} Mon.{r.setupCostCents ? ` · Setup ${eur(r.setupCostCents)}` : ''}
}{r.sellNetCents === null &&
{r.costGrossCents === null ? 'Endung nicht in Preisliste' : 'Aufschlag fehlt'}
} {!orgId && {r.customer ? <>{r.customer}
{r.customerNumber}
: –}}{money(r.costGrossCents)}
inkl. USt
{money(r.costNetCents)}{money(r.sellGrossCents)}
inkl. {r.taxBp / 100} % USt
{money(r.sellNetCents)}{money(r.profitNetCents)} {w ? <> - {r.procurement === 'ordered' &&
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 && {fmt(r.orderedAt)}}
} : PROC[r.procurement]} + {r.procurement === 'ordered' &&
setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedAt: ref[r.id] }))} />
} : PROC[r.procurement]} {w &&
{r.orgId && Ticket erstellen}
}))} Summe ({list.length}){!orgId && }{eur(sum((r) => r.costGrossCents))}{eur(sum((r) => r.costNetCents))}{eur(sum((r) => r.sellGrossCents))}{eur(sum((r) => r.sellNetCents))}{eur(sum((r) => r.profitNetCents))}
} From 466bd83b7e12c8943f972cad8bf45644f1f8c18a Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 12:02:26 +0200 Subject: [PATCH 19/28] 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 --- apps/api/src/modules/orders/index.ts | 40 ++++++++++++++++- apps/api/test/domains.test.ts | 5 ++- apps/api/test/orders.test.ts | 26 +++++++++++ .../web/src/app/(app)/vertraege/[id]/page.tsx | 43 ++++++++++++++++--- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 89f045e..22d35b8 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -53,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'] }, }, @@ -188,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) => { @@ -220,6 +220,42 @@ export const ordersModule: KcModule = { 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:00`); + 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, 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, 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); diff --git a/apps/api/test/domains.test.ts b/apps/api/test/domains.test.ts index 0dd4fda..3921bdb 100644 --- a/apps/api/test/domains.test.ts +++ b/apps/api/test/domains.test.ts @@ -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 }); diff --git a/apps/api/test/orders.test.ts b/apps/api/test/orders.test.ts index 5f34934..8249a31 100644 --- a/apps/api/test/orders.test.ts +++ b/apps/api/test/orders.test.ts @@ -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(); diff --git a/apps/web/src/app/(app)/vertraege/[id]/page.tsx b/apps/web/src/app/(app)/vertraege/[id]/page.tsx index c6ef3ff..c0ef64c 100644 --- a/apps/web/src/app/(app)/vertraege/[id]/page.tsx +++ b/apps/web/src/app/(app)/vertraege/[id]/page.tsx @@ -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(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null); + const [d, setD] = useState(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('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); }, [load]); if (!d) return msg ? {msg.t} :

Wird geladen …

; 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) { + 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 (<>

← Alle Verträge

@@ -30,8 +43,26 @@ export default function Vertrag() {
Verlängerung
{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}
Kündigungsfrist
{d.noticeDays} Tage
Abrechnung
{INTERVAL[d.price.interval]}
-

Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.

+

{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.`}

{d.resourceId &&

Zugehörige Ressource ansehen

} + {d.canEdit &&

Vertrag anpassen

+ {!editing ? <>

Nur für Superadmins. Preis und Konditionen werden mit Begründung im Audit-Protokoll festgehalten.

: ( + +
+ + + + + + + + + +
+ +
+ )} +
} {(d.canCancel || pending) &&

Kündigung

{confirm ? (

{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'} {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.

) From c276eb8ceb70f23345791b70b658886a6d3394db Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 12:24:48 +0200 Subject: [PATCH 20/28] 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 --- apps/api/src/modules/catalog/index.ts | 8 +++++--- apps/web/src/app/(app)/bestellen/page.tsx | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/modules/catalog/index.ts b/apps/api/src/modules/catalog/index.ts index 42aa2ce..5c31527 100644 --- a/apps/api/src/modules/catalog/index.ts +++ b/apps/api/src/modules/catalog/index.ts @@ -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 }; })); }); }, diff --git a/apps/web/src/app/(app)/bestellen/page.tsx b/apps/web/src/app/(app)/bestellen/page.tsx index 37ef405..275ac56 100644 --- a/apps/web/src/app/(app)/bestellen/page.tsx +++ b/apps/web/src/app/(app)/bestellen/page.tsx @@ -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() {

Neue Bestellung

{err && {err}} {staff &&
} {cat === null ?

Wird geladen …

: cat.length === 0 ?
Aktuell sind keine Produkte für die Selbstbestellung freigegeben.
: (<> -
{cat.map((p) =>

{p.name}

{p.description &&

{p.description}

} +
{cat.map((p) =>

{p.name}

{staff && p.orderableByCustomer === false &&

Nur durch das Personal bestellbar

}{p.description &&

{p.description}

}

{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage

)}
From 711b99bb342160471b9b1878d39bfeef340b230e Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 23:29:21 +0200 Subject: [PATCH 21/28] 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 --- apps/api/src/modules/orders/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 22d35b8..978ad80 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -243,15 +243,15 @@ export const ordersModule: KcModule = { } 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:00`); + 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, 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, renewal, renewalTermMonths, noticeDays, reason: b.reason } }, c); + 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 }; }); }); From 8ef0b3bc05bc49ca44066ebd63df25aea9ea1c18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:11:44 +0200 Subject: [PATCH 22/28] =?UTF-8?q?Discord:=20Tickets=20als=20eigene=20Kan?= =?UTF-8?q?=C3=A4le=20in=20einer=20Kategorie,=20Ticket-Meldungen=20nur=20f?= =?UTF-8?q?=C3=BCr=20den=20Support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/discord/index.ts | 7 + .../app/(app)/einstellungen/discord/page.tsx | 15 +- apps/worker/src/discord.ts | 148 ++++++++++++++++-- apps/worker/src/index.ts | 4 +- apps/worker/src/jobs.ts | 2 +- migrations/038_discord_ticket_channels.sql | 7 + 6 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 migrations/038_discord_ticket_channels.sql diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts index 9406a8e..a1ffa11 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -12,6 +12,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 +31,17 @@ 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); 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); diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index fad2bc9..ec6d536 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -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 }) {
  1. Anwendung anlegen: Auf discord.com/developers/applications auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).
  2. Bot-Token erzeugen: 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 Message Content Intent aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).
  3. -
  4. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  5. +
  6. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  7. Kunden-Anmeldung (OAuth) einrichten: Im Reiter „OAuth2 → General“ die Client ID und (unter „Reset Secret“) das Client Secret kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: {redirectUri}
  8. IDs ermitteln: 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.
  9. Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.
  10. @@ -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() { - + +
+

Tickets als eigene Kanäle

+

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 #ticket-log angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.

+
+ + +

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index e68dcce..e3ab75f 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -1,15 +1,24 @@ -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'; -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 { 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, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key }); async function nextTicketNumber(): Promise { @@ -63,7 +72,8 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise 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 +91,9 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise // 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 { @@ -100,7 +112,8 @@ async function handleClose(i: ChatInputCommandInteraction): Promise { 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 { @@ -120,9 +133,9 @@ async function handle(i: ChatInputCommandInteraction): Promise { } } -/** 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 von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten werden ignoriert (Web-Oberfläche bleibt die Quelle). */ async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise { - if (msg.author.bot || !msg.channel.isThread()) return; + 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 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; } @@ -138,10 +151,114 @@ async function handleThreadMessage(msg: Message, log: (m: string) => void): Prom 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>(); +function serial(key: string, fn: () => Promise): Promise { + 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 { + 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_Antworten des Kunden hier werden ins Ticket übernommen. Antworten des Supports bitte im Kundencenter schreiben._`.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 { + 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 { + 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 { 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 +277,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 { client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] }); @@ -196,13 +315,14 @@ export async function syncDiscord(log: (m: string) => void): Promise { } } -/** 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'; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1869916..b74eba8 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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(); diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index c542d9a..3f8b37f 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -23,7 +23,7 @@ const handlers: Record = { : 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 }) => { diff --git a/migrations/038_discord_ticket_channels.sql b/migrations/038_discord_ticket_channels.sql new file mode 100644 index 0000000..785283b --- /dev/null +++ b/migrations/038_discord_ticket_channels.sql @@ -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; From 9336f46d0afe5e61b322ee4f16402c78165d2214 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:29:29 +0200 Subject: [PATCH 23/28] =?UTF-8?q?Discord:=20Support-Antworten=20im=20Ticke?= =?UTF-8?q?t-Kanal=20als=20Antwort=20ins=20Ticket=20=C3=BCbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/worker/src/discord.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index e3ab75f..35d00ac 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -4,6 +4,7 @@ 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'; interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; @@ -133,18 +134,29 @@ async function handle(i: ChatInputCommandInteraction): Promise { } } -/** Nachricht im Ticket-Thread bzw. Ticket-Kanal von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten 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 { 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 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]); + 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}`); @@ -201,7 +213,7 @@ async function ensureTicketChannel(s: DiscordSettings, ticketId: string, withOpe 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_Antworten des Kunden hier werden ins Ticket übernommen. Antworten des Supports bitte im Kundencenter schreiben._`.slice(0, 2000), allowedMentions: { parse: [] } }); + 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: [] } }); From a322166d01f40af6b03ea83d432d4944c49ffed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:53:20 +0200 Subject: [PATCH 24/28] =?UTF-8?q?Lizenzverwaltung:=20=C3=9Cbersicht,=20Ver?= =?UTF-8?q?gabe,=20Aktivierungen,=20Lebenszyklus=20und=20Kundensicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/index.ts | 3 +- apps/api/src/modules/licensing/index.ts | 266 ++++++++++++++++++ apps/web/src/app/(app)/dashboard/page.tsx | 3 + apps/web/src/app/(app)/layout.tsx | 2 +- apps/web/src/app/(app)/lizenzen/[id]/page.tsx | 170 +++++++++++ apps/web/src/app/(app)/lizenzen/neu/page.tsx | 107 +++++++ apps/web/src/app/(app)/lizenzen/page.tsx | 68 +++++ apps/web/src/components/Licenses.tsx | 16 ++ packages/connector-licensing/src/admin.ts | 110 ++++++++ packages/connector-licensing/src/index.ts | 67 +++-- packages/connectors/src/index.ts | 3 + 11 files changed, 784 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/modules/licensing/index.ts create mode 100644 apps/web/src/app/(app)/lizenzen/[id]/page.tsx create mode 100644 apps/web/src/app/(app)/lizenzen/neu/page.tsx create mode 100644 apps/web/src/app/(app)/lizenzen/page.tsx create mode 100644 apps/web/src/components/Licenses.tsx create mode 100644 packages/connector-licensing/src/admin.ts diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index fb61559..f0f59d6 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -14,6 +14,7 @@ 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, licenseModule]; +export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule, licensingModule]; diff --git a/apps/api/src/modules/licensing/index.ts b/apps/api/src/modules/licensing/index.ts new file mode 100644 index 0000000..038531a --- /dev/null +++ b/apps/api/src/modules/licensing/index.ts @@ -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 = (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; limits?: Record }>(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 { + 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[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> | 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((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 = {}; + 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 = { 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 }; + }); + }, +}; diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index adc8776..38cc7a7 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -9,6 +9,7 @@ interface Sys { jobs: Record; 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(null); const [sys, setSys] = useState(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('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e))); if (can('jobs.read')) api('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() { ) : (<>
{customers !== null &&
{customers}

Kunden

Kunden verwalten
} + {licenses &&
{licenses.active}

Aktive Lizenzen

{licenses.expiring > 0 &&

{licenses.expiring} laufen in 30 Tagen ab

}{licenses.unassigned > 0 &&

{licenses.unassigned} ohne Kunde

}Lizenzen verwalten
} {sys &&
{sys.jobs.scheduled ?? 0}

Wartende Aufträge

} {sys?.backup &&

Backup

Details und Einstellungen

{!sys.backup.configured ? <>

Nicht eingerichtet.

Kein Backup diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index dd1eeb5..cd93719 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -21,7 +21,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { ); async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } 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('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 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 = (