From 8243cdb111c881e0a58bfdf37f178dfce883665f Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 00:05:38 +0200 Subject: [PATCH] feat(discord): Ticket-Befehle + saubere Rechtetrennung Kunde/Personal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei neue Slash-Befehle: - /ticket (jeder verknüpfte Kunde): erstellt direkt ein neues Ticket samt zugehörigem privaten Thread, ganz ohne Web-Besuch. - /zuweisen und /schliessen (nur Personal, nur innerhalb eines Ticket-Threads): weisen das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu bzw. schließen es. Rechtetrennung (Kunden: nur eigenes Ticket erstellen/lesen, Personal: mehr Rechte): - Zuweisen/Schließen prüfen serverseitig ein verknüpftes Personal-Konto (kind='staff'), unabhängig von der separaten staffUserIds-Liste für /kc-status und /kc-kunde. - Alle vier Personal-Befehle sind zusätzlich per setDefaultMemberPermissions(ManageThreads) so eingestellt, dass sie Kunden gar nicht erst in der Befehlsliste angezeigt werden (die serverseitige Prüfung bleibt trotzdem bestehen, das ist nur die UI-Sichtbarkeit). - Bugfix dabei gefunden: eine Kundenantwort im Thread eines bereits geschlossenen Tickets hat es bisher unbemerkt wieder geöffnet (die Web-Oberfläche verhindert das schon lange). Jetzt wird das erkannt und die Antwort mit einem Hinweis abgelehnt, wie im Web. Anleitung unter Einstellungen > Discord entsprechend ergänzt. Co-Authored-By: Claude Sonnet 5 --- .../app/(app)/einstellungen/discord/page.tsx | 2 + apps/worker/src/discord.ts | 68 +++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index 49692c8..9a178b1 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -17,6 +17,8 @@ function Guide({ redirectUri }: { redirectUri: string }) {
  • 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}
  • 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.
  • Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die /kc-status und /kc-kunde nutzen dürfen.
  • +
  • Verfügbare Befehle: Kunden mit verknüpftem Konto können mit /ticket direkt ein neues Ticket eröffnen. Personal kann innerhalb eines Ticket-Threads mit /zuweisen und /schliessen das Ticket zuweisen bzw. schließen – dafür muss die jeweilige Person ihr eigenes Discord-Konto ebenfalls unter „Mein Konto“ verknüpft haben.
  • +
  • Saubere Trennung: Kunden sehen und nutzen ausschließlich /ticket sowie ihren eigenen Ticket-Thread. Die Personal-Befehle sind zusätzlich so eingestellt, dass sie nur Discord-Mitgliedern mit der Server-Berechtigung „Threads verwalten“ überhaupt in der Befehlsliste angezeigt werden – vergeben Sie diese Berechtigung über eine eigene Rolle nur an Ihr Support-Team, nicht an die Kundenrolle.
  • ); } diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index d8a4415..23a0677 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -1,4 +1,4 @@ -import { ChannelType, Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js'; +import { ChannelType, Client, GatewayIntentBits, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js'; import { randomUUID } from 'node:crypto'; import { pool } from '@kc/platform/db'; import { decrypt } from '@kc/platform/crypto'; @@ -12,6 +12,15 @@ async function loadSettings(): Promise { return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null, staffUserIds: String(s?.staff_user_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 { + await pool.query("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'"); + const [r] = await pool.query('SELECT LAST_INSERT_ID() AS n') as any; + return `T-${r[0].n}`; +} +const linkedUser = (discordUserId: string, kind: 'customer' | 'staff') => + pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = ? AND status = 'active'", [discordUserId, kind]).then(([r]: any) => r[0]); +const ticketForThread = (channelId: string) => + pool.query('SELECT tt.ticket_id, tk.number FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [channelId]).then(([r]: any) => r[0]); let client: Client | null = null; let lastFingerprint = ''; @@ -19,13 +28,63 @@ let cachedEnabled = false; export const discordEnabled = () => cachedEnabled; export const discordReady = () => !!client?.isReady(); +// Personal-Befehle sind standardmäßig nur für Discord-Mitglieder mit der Berechtigung "Threads verwalten" sichtbar +// (zusätzlich zur eigentlichen Prüfung im Code, die immer ein verknüpftes Personal-Konto verlangt). Kunden bekommen +// diese Berechtigung auf dem Server nicht, sehen also nur /ticket – so bleibt die Trennung auch in der Befehlsliste sauber. +const staffOnly = PermissionFlagsBits.ManageThreads; const commands = [ - new SlashCommandBuilder().setName('kc-status').setDescription('Zustand des Kundencenters (Aufträge, Datenbank)'), - new SlashCommandBuilder().setName('kc-kunde').setDescription('Kunde suchen (nur Kundennummer, Name, Status)') + new SlashCommandBuilder().setName('kc-status').setDescription('Zustand des Kundencenters (Aufträge, Datenbank)').setDefaultMemberPermissions(staffOnly), + new SlashCommandBuilder().setName('kc-kunde').setDescription('Kunde suchen (nur Kundennummer, Name, Status)').setDefaultMemberPermissions(staffOnly) .addStringOption((o) => o.setName('suche').setDescription('Kundennummer oder Name').setRequired(true).setMaxLength(100)), + new SlashCommandBuilder().setName('ticket').setDescription('Neues Support-Ticket erstellen (eigenes verknüpftes Kundencenter-Konto)') + .addStringOption((o) => o.setName('betreff').setDescription('Kurzer Betreff').setRequired(true).setMaxLength(200)) + .addStringOption((o) => o.setName('text').setDescription('Ihr Anliegen').setRequired(true).setMaxLength(2000)), + new SlashCommandBuilder().setName('zuweisen').setDescription('Dieses Ticket einer Person zuweisen (nur im Ticket-Thread, nur Personal)').setDefaultMemberPermissions(staffOnly) + .addUserOption((o) => o.setName('mitarbeiter').setDescription('Zuständige Person (muss Discord verknüpft haben)').setRequired(true)), + new SlashCommandBuilder().setName('schliessen').setDescription('Dieses Ticket schließen (nur im Ticket-Thread, nur Personal)').setDefaultMemberPermissions(staffOnly), ].map((c) => c.toJSON()); +/** Kunde eröffnet ein neues Ticket direkt aus Discord; legt Ticket + zugehörigen Thread an. */ +async function handleTicketCreate(i: ChatInputCommandInteraction): Promise { + const u = await linkedUser(i.user.id, 'customer'); + if (!u) { await i.reply({ content: 'Bitte zuerst dein Konto im Kundencenter verknüpfen (unter „Mein Konto“ → „Mit Discord verbinden“).', ephemeral: true }); return; } + const mem = await pool.query("SELECT org_id FROM memberships WHERE user_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [u.id]).then(([r]: any) => r[0]); + if (!mem) { await i.reply({ content: 'Für dein Konto ist keine Organisation hinterlegt.', ephemeral: true }); return; } + const subject = i.options.getString('betreff', true).slice(0, 200); + const body = i.options.getString('text', true).slice(0, 2000); + await i.deferReply({ ephemeral: true }); + const ticketId = randomUUID(); + const number = await nextTicketNumber(); + await pool.query('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', [ticketId, number, mem.org_id, subject, 'pending_staff', 'normal', u.id]); + await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, u.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.` }); +} +/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */ +async function handleAssign(i: ChatInputCommandInteraction): Promise { + if (!(await linkedUser(i.user.id, 'staff'))) { await i.reply({ content: 'Nur für verknüpfte Personal-Konten.', ephemeral: true }); return; } + const t = await ticketForThread(i.channelId); + if (!t) { await i.reply({ content: 'Dieser Befehl funktioniert nur innerhalb eines Ticket-Threads.', ephemeral: true }); return; } + const target = i.options.getUser('mitarbeiter', true); + const assignee = await linkedUser(target.id, 'staff'); + if (!assignee) { await i.reply({ content: `${target.username} hat kein verknüpftes Personal-Konto.`, ephemeral: true }); return; } + await pool.query('UPDATE tickets SET assigned_to = ? WHERE id = ?', [assignee.id, t.ticket_id]); + await i.reply({ content: `Ticket ${t.number} wurde ${assignee.name} zugewiesen.` }); +} +/** Schließt das Ticket des aktuellen Threads. */ +async function handleClose(i: ChatInputCommandInteraction): Promise { + if (!(await linkedUser(i.user.id, 'staff'))) { await i.reply({ content: 'Nur für verknüpfte Personal-Konten.', ephemeral: true }); return; } + 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.` }); +} + async function handle(i: ChatInputCommandInteraction): Promise { + if (i.commandName === 'ticket') return handleTicketCreate(i); + if (i.commandName === 'zuweisen') return handleAssign(i); + if (i.commandName === 'schliessen') return handleClose(i); // Alle Antworten ephemeral: nie vertrauliche Daten in Kanälen sichtbar machen. const s = await loadSettings(); if (!s.staffUserIds.includes(i.user.id)) { await i.reply({ content: 'Keine Berechtigung.', ephemeral: true }); return; } @@ -43,8 +102,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). */ async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise { if (msg.author.bot || !msg.channel.isThread()) return; - const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id 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 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]);