From 8ef0b3bc05bc49ca44066ebd63df25aea9ea1c18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:11:44 +0200 Subject: [PATCH 1/5] =?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 2/5] =?UTF-8?q?Discord:=20Support-Antworten=20im=20Ticket-?= =?UTF-8?q?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 3/5] =?UTF-8?q?Lizenzverwaltung:=20=C3=9Cbersicht,=20Verga?= =?UTF-8?q?be,=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 = (