diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx
index 9483f04..fad2bc9 100644
--- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx
+++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx
@@ -17,7 +17,7 @@ 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.
- Wer darf was: Es gibt keine separate Liste mehr – maßgeblich ist allein, ob und wie ein Discord-Konto unter „Mein Konto“ mit einem Kundencenter-Zugang verknüpft ist. Kunden mit verknüpftem Konto können mit /ticket ein neues Ticket eröffnen. Personal-Konten (Mitarbeiter, die sich ebenfalls unter „Mein Konto“ verknüpfen) dürfen zusätzlich /kc-status, /kc-kunde sowie innerhalb eines Ticket-Threads /zuweisen und /schliessen nutzen.
+ Wer darf was: Es gibt keine separate Liste mehr – maßgeblich ist allein, ob und wie ein Discord-Konto unter „Mein Konto“ mit einem Kundencenter-Zugang verknüpft ist. Ein Discord-Konto kann immer nur mit einem Kundencenter-Konto verknüpft sein (entweder Kunde oder Personal). Kunden können mit /ticket ein eigenes Ticket eröffnen; Personal kann mit /ticket kunde:… ein Ticket für einen beliebigen Kunden anlegen und nutzt zusätzlich /kc-status, /kc-kunde sowie innerhalb eines Ticket-Threads /zuweisen und /schliessen.
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 (reine Sichtbarkeit – die eigentliche Prüfung ist immer das verknüpfte Personal-Konto). 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 346e54d..e68dcce 100644
--- a/apps/worker/src/discord.ts
+++ b/apps/worker/src/discord.ts
@@ -36,30 +36,52 @@ const commands = [
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)')
+ new SlashCommandBuilder().setName('ticket').setDescription('Neues Support-Ticket erstellen')
.addStringOption((o) => o.setName('betreff').setDescription('Kurzer Betreff').setRequired(true).setMaxLength(200))
- .addStringOption((o) => o.setName('text').setDescription('Ihr Anliegen').setRequired(true).setMaxLength(2000)),
+ .addStringOption((o) => o.setName('text').setDescription('Anliegen').setRequired(true).setMaxLength(2000))
+ .addStringOption((o) => o.setName('kunde').setDescription('Nur für Personal: für welchen Kunden (Kundennummer oder Name)').setRequired(false).setMaxLength(100)),
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. */
+/** Erstellt ein neues Ticket direkt aus Discord; legt bei verknüpften Kunden auch den zugehörigen Thread an.
+ * Kunden eröffnen ihr eigenes Ticket, Personal kann es für einen beliebigen Kunden anlegen (Option "kunde"). */
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);
+ const kunde = i.options.getString('kunde')?.trim();
+
+ const customer = await linkedUser(i.user.id, 'customer');
+ if (customer) {
+ const mem = await pool.query("SELECT org_id FROM memberships WHERE user_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [customer.id]).then(([r]: any) => r[0]);
+ if (!mem) { await i.reply({ content: 'Für dein Konto ist keine Organisation hinterlegt.', ephemeral: true }); return; }
+ 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', customer.id]);
+ 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.` });
+ return;
+ }
+
+ const staff = await linkedUser(i.user.id, 'staff');
+ if (!staff) { await i.reply({ content: 'Dein Discord-Konto ist mit keinem Kundencenter-Konto verknüpft (unter „Mein Konto“ → „Mit Discord verbinden“).', ephemeral: true }); return; }
+ if (!kunde) { await i.reply({ content: 'Als Personal bitte die Option „kunde“ angeben (Kundennummer oder Name), für wen das Ticket ist.', ephemeral: true }); return; }
+ const orgs = await pool.query("SELECT id, name, customer_number FROM organizations WHERE customer_number = ? OR name LIKE CONCAT('%', ?, '%') LIMIT 2", [kunde, kunde]) as [any[], unknown];
+ const matches = orgs[0];
+ if (matches.length === 0) { await i.reply({ content: `Kein Kunde gefunden für „${kunde}“.`, ephemeral: true }); return; }
+ if (matches.length > 1) { await i.reply({ content: `Mehrdeutig – bitte die genaue Kundennummer angeben (z. B. gefunden: ${matches.map((m) => m.customer_number).join(', ')}).`, ephemeral: true }); return; }
+ const org = matches[0];
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.` });
+ // 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.` });
}
/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */
async function handleAssign(i: ChatInputCommandInteraction): Promise {