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: [] } });