Support-Tickets: Modul, Oberfläche, Discord-Benachrichtigung

This commit is contained in:
Kundencenter 2026-09-27 08:36:18 +02:00
parent fd34d121b1
commit ca5b48ab47
12 changed files with 340 additions and 5 deletions

View file

@ -12,7 +12,8 @@ Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, Ty
Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen),
Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional).
Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`).
Noch **nicht** vorhanden: Rechnungen/Zahlungen, Tickets, Selbstregistrierung, KeyHelp/Plesk-Connector.
Support-Tickets (je Kunde, mit internen Notizen für Personal, Discord-Benachrichtigung).
Noch **nicht** vorhanden: Rechnungen/Zahlungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung).
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).

View file

@ -7,8 +7,9 @@ import { connectorsModule } from './connectors/index.js';
import { resourcesModule } from './resources/index.js';
import { domainsModule } from './domains/index.js';
import { catalogModule } from './catalog/index.js';
import { ticketsModule } from './tickets/index.js';
import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/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, backupModule];
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, backupModule];

View file

@ -0,0 +1,132 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const OPEN = new Set(['open', 'pending_customer', 'pending_staff']);
async function nextNumber(c: PoolConnection): Promise<string> {
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c);
return `T-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
}
const ticketView = (t: any) => ({
id: t.id, number: t.number, orgId: t.org_id, orgName: t.org_name, customerNumber: t.customer_number, resourceId: t.resource_id, subject: t.subject,
status: t.status, priority: t.priority, createdBy: t.created_by, assignedTo: t.assigned_to, assignedName: t.assigned_name ?? null,
createdAt: t.created_at, updatedAt: t.updated_at, lastMessageAt: t.last_message_at, resolvedAt: t.resolved_at, closedAt: t.closed_at,
});
const TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS assigned_name FROM tickets t JOIN organizations g ON g.id = t.org_id LEFT JOIN users u ON u.id = t.assigned_to`;
const messageView = (m: any) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at });
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
/** Meldet neue Nachrichten/Status auf CUSTOMER-sichtbare Zeilen; interne Notizen werden für Kunden ausgefiltert. */
async function loadTicket(id: string, forCustomer: boolean) {
const t = await one(`${TICKET_SQL} WHERE t.id = ?`, [id]);
if (!t) return null;
const msgs = await query(
`SELECT m.*, u.name AS author_name FROM ticket_messages m JOIN users u ON u.id = m.author_id WHERE m.ticket_id = ?${forCustomer ? ' AND m.internal_note = 0' : ''} ORDER BY m.created_at`, [id]);
return { t, msgs };
}
export const ticketsModule: KcModule = {
name: 'tickets',
permissions: {
staff: { support: ['tickets.read', 'tickets.write'], accounting: ['tickets.read'], admin: ['tickets.read', 'tickets.write'], superadmin: ['tickets.read', 'tickets.write'] },
org: { owner: ['tickets.read', 'tickets.create'], admin: ['tickets.read', 'tickets.create'], member: ['tickets.read', 'tickets.create'] },
},
register(app: FastifyInstance) {
app.post('/tickets', async (req) => {
const a = requireAuth(req);
const b = z.object({ orgId: z.string().uuid(), subject: z.string().trim().min(3).max(200), body: z.string().trim().min(1).max(10000), resourceId: z.string().uuid().optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal') }).parse(req.body);
const staff = can(a.principal, 'tickets.write');
if (!canInOrg(a.principal, b.orgId, 'tickets.create', 'tickets.write')) { if (!canInOrg(a.principal, b.orgId, 'tickets.read', 'tickets.read')) throw notFound(); throw forbidden(); }
if (!staff && b.priority !== 'normal' && b.priority !== 'high') throw forbidden('Diese Priorität kann nur vom Personal gesetzt werden', 'PRIORITY_FORBIDDEN');
if (b.resourceId && !(await one('SELECT 1 AS x FROM resources WHERE id = ? AND org_id = ?', [b.resourceId, b.orgId]))) throw badRequest('Ressource gehört nicht zu diesem Kunden');
const id = randomUUID();
const number = await tx(async (c) => {
const n = await nextNumber(c);
await run('INSERT INTO tickets (id, number, org_id, resource_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?,?)',
[id, n, b.orgId, b.resourceId ?? null, b.subject, staff ? 'pending_customer' : 'pending_staff', b.priority, a.user.id], c);
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body], c);
return n;
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } });
if (!staff) await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
return { id, number };
});
app.get('/tickets', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.enum(['open', 'closed']).optional(), assignedToMe: z.coerce.boolean().optional() }).parse(req.query);
const staff = can(a.principal, 'tickets.read');
const myOrgs = a.principal.memberships.map((m) => m.orgId);
if (!staff && myOrgs.length === 0) return [];
if (staff && q.org && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [q.org]))) throw notFound();
const orgs = staff ? (q.org ? [q.org] : null) : myOrgs;
const statusSet = q.status === 'closed' ? ['resolved', 'closed'] : q.status === 'open' ? [...OPEN] : null;
const orgPlaceholders = orgs ? orgs.map(() => '?').join(',') : '';
const statusPlaceholders = statusSet ? statusSet.map(() => '?').join(',') : '';
const rows = await query(
`${TICKET_SQL} WHERE (${orgs ? `t.org_id IN (${orgPlaceholders})` : '1=1'}) AND (${statusSet ? `t.status IN (${statusPlaceholders})` : '1=1'}) AND (? IS NULL OR t.assigned_to = ?) ORDER BY t.last_message_at DESC LIMIT 200`,
[...(orgs ?? []), ...(statusSet ?? []), q.assignedToMe ? 1 : null, q.assignedToMe ? a.user.id : null]);
return rows.map(ticketView);
});
app.get('/tickets/:id', async (req) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const staff = can(a.principal, 'tickets.read');
const res = await loadTicket(id, !staff);
if (!res || !canInOrg(a.principal, res.t.org_id, 'tickets.read', 'tickets.read')) throw notFound();
return { ...ticketView(res.t), messages: res.msgs.map(messageView), canWrite: staff || canInOrg(a.principal, res.t.org_id, 'tickets.create', 'tickets.write') };
});
app.post('/tickets/:id/messages', async (req) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ body: z.string().trim().min(1).max(10000), internalNote: z.boolean().default(false) }).parse(req.body);
const staff = can(a.principal, 'tickets.write');
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound();
if (!staff && !canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound();
if (b.internalNote && !staff) throw forbidden('Interne Notizen sind nur für Personal', 'INTERNAL_NOTE_FORBIDDEN');
if (t.status === 'closed') throw conflict('Das Ticket ist geschlossen. Bitte ein neues Ticket eröffnen.', 'TICKET_CLOSED');
const nextStatus = b.internalNote ? t.status : staff ? 'pending_customer' : 'pending_staff';
await tx(async (c) => {
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body, b.internalNote ? 1 : 0], c);
await run('UPDATE tickets SET status = ?, last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?', [nextStatus, id], c);
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } });
if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId);
return { ok: true };
});
app.patch('/tickets/:id', async (req) => {
const a = requirePermission(req, 'tickets.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ status: z.enum(['open', 'pending_customer', 'pending_staff', 'resolved', 'closed']).optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), assignedTo: z.string().uuid().nullable().optional() }).parse(req.body);
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound();
if (b.assignedTo && !(await one("SELECT 1 AS x FROM users WHERE id = ? AND kind = 'staff'", [b.assignedTo]))) throw badRequest('Nicht gefunden', 'ASSIGNEE_NOT_FOUND');
const sets: string[] = []; const params: unknown[] = [];
if (b.status) { sets.push('status = ?', 'resolved_at = ?', 'closed_at = ?'); params.push(b.status, b.status === 'resolved' ? new Date() : null, b.status === 'closed' ? new Date() : null); }
if (b.priority) { sets.push('priority = ?'); params.push(b.priority); }
if (b.assignedTo !== undefined) { sets.push('assigned_to = ?'); params.push(b.assignedTo); }
if (sets.length === 0) return { ok: true };
await run(`UPDATE tickets SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.update', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: b });
return { ok: true };
});
// Kunde kann sein eigenes gelöstes Ticket erneut öffnen oder als erledigt schließen
app.post('/tickets/:id/close', async (req) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound();
if (!canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound();
if (!OPEN.has(t.status) && t.status !== 'resolved') throw conflict('Das Ticket ist bereits geschlossen.', 'TICKET_CLOSED');
await run("UPDATE tickets SET status = 'closed', closed_at = UTC_TIMESTAMP(3) WHERE id = ?", [id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.close', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { ok: true };
});
},
};

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });

View file

@ -0,0 +1,57 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
describe('Support-Tickets', () => {
it('Kunde eröffnet, Personal antwortet, interne Notiz bleibt verborgen, Zuweisung, Schließen, Mandantentrennung', async () => {
const admin = await staff('tix-admin@example.com', 'admin'); const support = await staff('tix-support@example.com', 'support'); const accounting = await staff('tix-acc@example.com', 'accounting');
const A = await customer(admin, 'Firma A', 'tix-a@example.com'); const B = await customer(admin, 'Firma B', 'tix-b@example.com');
const create = await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Server langsam', body: 'Seit heute Morgen sehr langsam.' });
expect(create.statusCode).toBe(200); const id = create.json().id;
// Kunde kann keine hohe Priorität setzen und nicht für einen fremden Kunden
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Test', body: 'y', priority: 'urgent' })).statusCode).toBe(403);
expect((await call(app, A.client, 'POST', '/tickets', { orgId: B.org, subject: 'Test', body: 'y' })).statusCode).toBe(404);
expect((await call(app, accounting, 'PATCH', `/tickets/${id}`, { priority: 'high' })).statusCode).toBe(403); // Buchhaltung nur lesend
const get1 = (await call(app, support, 'GET', `/tickets/${id}`)).json(); expect(get1.status).toBe('pending_staff'); expect(get1.messages).toHaveLength(1);
expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Interne Notiz: Ticket klingt nach Netzwerkproblem', internalNote: true })).statusCode).toBe(200);
expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Wir prüfen das und melden uns.' })).statusCode).toBe(200);
expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { assignedTo: null })).statusCode).toBe(200);
// Kunde sieht die interne Notiz nicht, aber die Antwort; Status ist jetzt "wartet auf Kunde"
const seenByCustomer = (await call(app, A.client, 'GET', `/tickets/${id}`)).json();
expect(seenByCustomer.status).toBe('pending_customer'); expect(seenByCustomer.messages).toHaveLength(2);
expect(seenByCustomer.messages.some((m: any) => m.body.includes('Interne Notiz'))).toBe(false);
expect((await call(app, B.client, 'GET', `/tickets/${id}`)).statusCode).toBe(404); // fremder Kunde
// Kunde antwortet erneut → wartet auf Personal
await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'Danke, bitte kurzfristig.' });
expect((await call(app, support, 'GET', `/tickets/${id}`)).json().status).toBe('pending_staff');
// Zuweisen, lösen, schließen
expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { status: 'resolved' })).statusCode).toBe(200);
expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(200);
expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(409); // schon geschlossen
expect((await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'noch was' })).statusCode).toBe(409);
// Listen: Kunde sieht nur eigene, Personal sieht alle offenen
const openB = (await call(app, admin, 'POST', '/tickets', { orgId: B.org, subject: 'Frage', body: 'Wie funktioniert X?' })).json();
expect((await call(app, B.client, 'GET', '/tickets')).json()).toHaveLength(1);
const staffOpen = (await call(app, support, 'GET', '/tickets?status=open')).json(); expect(staffOpen.map((t: any) => t.id)).toContain(openB.id); expect(staffOpen.map((t: any) => t.id)).not.toContain(id);
expect((await call(app, support, 'GET', '/tickets?status=closed')).json().map((t: any) => t.id)).toEqual([id]);
});
});

View file

@ -22,6 +22,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{!enrollNeeded && (me.kind === 'customer' || can('resources.read')) && link('/ressourcen', 'Ressourcen')}
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')}
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')}
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && link('/tickets', 'Tickets')}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && <>

View file

@ -0,0 +1,51 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, TicketStatusBadge, TICKET_PRIORITY, fmt } from '@/components/ui';
interface Msg { id: string; authorId: string; authorName: string; authorKind: 'customer' | 'staff'; body: string; internalNote: boolean; createdAt: string }
interface Detail { id: string; number: string; orgId: string; orgName: string; subject: string; status: string; priority: string; assignedTo: string | null; assignedName: string | null; messages: Msg[]; canWrite: boolean }
interface StaffUser { id: string; name: string; staffRole: string | null }
const STATUS_OPTIONS = { open: 'Offen', pending_customer: 'Wartet auf Kunde', pending_staff: 'Wartet auf Personal', resolved: 'Gelöst', closed: 'Geschlossen' };
export default function TicketDetail() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('tickets.read'); const write = can('tickets.write');
const [t, setT] = useState<Detail | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
const [staffUsers, setStaffUsers] = useState<StaffUser[]>([]);
const load = useCallback(() => api<Detail>('GET', `/tickets/${id}`).then(setT).catch((e) => setErr(errMsg(e))), [id]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (write) api<StaffUser[]>('GET', '/admin/users').then((l) => setStaffUsers(l.filter((u) => !!u.staffRole))).catch(() => undefined); }, [write]);
async function send(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = e.currentTarget; const body = String(new FormData(f).get('body') ?? ''); const internal = new FormData(f).get('internal') === 'on';
try { await api('POST', `/tickets/${id}/messages`, { body, internalNote: internal }); f.reset(); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
async function patch(body: object, msg?: string) { setErr(''); setOk(''); try { await api('PATCH', `/tickets/${id}`, body); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } }
async function close() { setErr(''); try { await api('POST', `/tickets/${id}/close`); void load(); } catch (x) { setErr(errMsg(x)); } }
if (!t) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const canClose = t.status !== 'closed';
return (<>
<div className="row between"><h1>{t.number}: {t.subject}</h1><Link className="btn" href="/tickets">← Tickets</Link></div>
<p className="muted small">{staff && <>{t.orgName} · </>}<TicketStatusBadge value={t.status} /> · Priorität: {TICKET_PRIORITY[t.priority]}{t.assignedName && <> · zugewiesen an {t.assignedName}</>}</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{write && <div className="card"><div className="cols">
<Field id="status" label="Status"><select id="status" value={t.status} onChange={(e) => patch({ status: e.target.value })}>{Object.entries(STATUS_OPTIONS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="priority" label="Priorität"><select id="priority" value={t.priority} onChange={(e) => patch({ priority: e.target.value })}>{Object.entries(TICKET_PRIORITY).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="assignee" label="Zugewiesen an"><select id="assignee" value={t.assignedTo ?? ''} onChange={(e) => patch({ assignedTo: e.target.value || null })}><option value="">Niemand</option>{staffUsers.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}</select></Field>
</div></div>}
<div className="card">
{t.messages.map((m) => (<div key={m.id} style={{ padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div className="row between small muted"><span>{m.authorName} ({m.authorKind === 'staff' ? 'Personal' : 'Kunde'}){m.internalNote && <> · <span className="badge warn">nur intern</span></>}</span><span>{fmt(m.createdAt)}</span></div>
<p style={{ whiteSpace: 'pre-wrap', margin: '4px 0 0' }}>{m.body}</p></div>))}
</div>
{t.status === 'closed' ? <p className="muted">Dieses Ticket ist geschlossen. Für neue Anliegen bitte ein neues Ticket eröffnen.</p> : t.canWrite && <form className="card" onSubmit={send}>
<Field id="body" label="Antwort"><textarea id="body" name="body" required rows={4} style={{ width: '100%' }} /></Field>
{staff && <label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="internal" style={{ width: 20, minHeight: 20 }} /> Nur intern (für den Kunden nicht sichtbar)</label>}
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Absenden</button>{!staff && canClose && <button className="btn" type="button" onClick={close}>Als erledigt schließen</button>}</div>
</form>}
</>);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, TicketStatusBadge, TICKET_PRIORITY, fmt } from '@/components/ui';
interface T { id: string; number: string; orgId: string; orgName: string; customerNumber: string; subject: string; status: string; priority: string; assignedName: string | null; lastMessageAt: string }
interface Cust { id: string; name: string; customerNumber: string }
const PRIO_CLS: Record<string, string> = { urgent: 'err', high: 'warn', normal: 'info', low: 'info' };
export default function Tickets() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('tickets.read');
const [list, setList] = useState<T[] | null>(null); const [err, setErr] = useState(''); const [status, setStatus] = useState<'open' | 'closed' | ''>('open'); const [mine, setMine] = useState(false);
const [open, setOpen] = useState(false); const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<T[]>('GET', `/tickets?${new URLSearchParams({ ...(status ? { status } : {}), ...(mine ? { assignedToMe: '1' } : {}) })}`).then(setList).catch((e) => setErr(errMsg(e))), [status, mine]);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch(() => undefined);
else if (me?.organizations[0]) setOrg(me.organizations[0].id);
}, [staff, me]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = new FormData(e.currentTarget);
try {
const t = await api<{ id: string }>('POST', '/tickets', { orgId: org, subject: f.get('subject'), body: f.get('body'), ...(staff && f.get('priority') ? { priority: f.get('priority') } : {}) });
r.push(`/tickets/${t.id}`);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
<div className="row between"><h1>Tickets</h1><button className="btn primary" onClick={() => setOpen(!open)} aria-expanded={open}>Neues Ticket</button></div>
{err && <Alert kind="err">{err}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neues Ticket</h2>
{staff && <Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => setOrg(e.target.value)}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
<Field id="subject" label="Betreff"><input id="subject" name="subject" required minLength={3} maxLength={200} autoFocus /></Field>
<Field id="body" label="Anliegen"><textarea id="body" name="body" required rows={5} style={{ width: '100%' }} /></Field>
{staff && <Field id="priority" label="Priorität"><select id="priority" name="priority" defaultValue="normal">{Object.entries(TICKET_PRIORITY).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>}
<div className="row"><button className="btn primary" type="submit" disabled={busy || !org}>Absenden</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value as never)}><option value="open">Offen</option><option value="closed">Geschlossen</option><option value="">Alle</option></select></Field>
{staff && <label className="row small" style={{ alignSelf: 'flex-end', marginBottom: 12 }}><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={mine} onChange={(e) => setMine(e.target.checked)} /> Nur mir zugewiesen</label>}
</div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Tickets">{status === 'open' ? 'Aktuell ist nichts offen.' : 'Nichts gefunden.'}</Empty> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Betreff</th>{staff && <th>Kunde</th>}<th>Priorität</th><th>Status</th>{staff && <th>Zugewiesen</th>}<th>Letzte Nachricht</th></tr></thead><tbody>
{list.map((t) => (<tr key={t.id}><td className="mono"><Link href={`/tickets/${t.id}`}>{t.number}</Link></td><td><Link href={`/tickets/${t.id}`}>{t.subject}</Link></td>{staff && <td>{t.orgName}</td>}
<td><span className={`badge ${PRIO_CLS[t.priority]}`}>{TICKET_PRIORITY[t.priority]}</span></td><td><TicketStatusBadge value={t.status} /></td>{staff && <td>{t.assignedName ?? <span className="muted small">–</span>}</td>}<td className="small">{fmt(t.lastMessageAt)}</td></tr>))}
</tbody></table></div>}
</div>
</>);
}

View file

@ -45,6 +45,12 @@ const CONTRACT: Record<string, [string, string, string]> = {
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
const TICKET: Record<string, [string, string, string]> = {
open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'],
resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'],
};
export const TicketStatusBadge = ({ value }: { value: string }) => badge(TICKET, value);
export const TICKET_PRIORITY: Record<string, string> = { low: 'Niedrig', normal: 'Normal', high: 'Hoch', urgent: 'Dringend' };
export interface Price { interval: string; quantity: number; taxBp: number; discountBp: number; setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number } }
/** Preisdarstellung: Privatkunden brutto, Geschäftskunden netto mit Brutto in Klammern. */
export function PriceText({ p, priv }: { p: Price; priv: boolean }) {

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,9 @@ const handlers: Record<string, Handler> = {
const text = p.event === 'customer.created' ? `Neuer Kunde angelegt: ${p.customerNumber}`
: p.event === 'backup.failed' ? `⚠ Backup fehlgeschlagen: ${p.detail ?? ''}`.trim()
: p.event === 'backup.stale' ? `⚠ Kein aktuelles Backup: ${p.detail ?? ''}`.trim()
: p.event === 'restoretest.failed' ? `⚠ Wiederherstellungstest fehlgeschlagen: ${p.detail ?? ''}`.trim() : null; // keine personenbezogenen Daten
: p.event === 'restoretest.failed' ? `⚠ Wiederherstellungstest fehlgeschlagen: ${p.detail ?? ''}`.trim()
: p.event === 'ticket.created' ? `🎫 Neues Ticket ${p.number}: ${p.subject ?? ''}`.trim()
: p.event === 'ticket.message' ? `🎫 Neue Nachricht zu Ticket ${p.number}` : null; // keine personenbezogenen Daten
if (!text) throw new Error(`Unbekanntes Ereignis: ${p.event}`);
const r = await notifyAdmin(text);
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';

View file

@ -0,0 +1,31 @@
-- Support-Tickets: je Organisation, mit Verlauf (Kunde/Personal), interne Notizen nur für Personal sichtbar
CREATE TABLE tickets (
id CHAR(36) PRIMARY KEY,
number VARCHAR(20) NOT NULL UNIQUE,
org_id CHAR(36) NOT NULL REFERENCES organizations(id),
resource_id CHAR(36) NULL REFERENCES resources(id),
subject VARCHAR(200) NOT NULL,
status ENUM('open','pending_customer','pending_staff','resolved','closed') NOT NULL DEFAULT 'pending_staff',
priority ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
created_by CHAR(36) NOT NULL REFERENCES users(id),
assigned_to CHAR(36) NULL REFERENCES users(id),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
last_message_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
resolved_at DATETIME(3) NULL,
closed_at DATETIME(3) NULL,
KEY idx_tickets_org (org_id), KEY idx_tickets_status (status), KEY idx_tickets_assigned (assigned_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE ticket_messages (
id CHAR(36) PRIMARY KEY,
ticket_id CHAR(36) NOT NULL REFERENCES tickets(id),
author_id CHAR(36) NOT NULL REFERENCES users(id),
author_kind ENUM('customer','staff') NOT NULL,
body TEXT NOT NULL,
internal_note TINYINT(1) NOT NULL DEFAULT 0, -- nur für Personal sichtbar, nie für den Kunden
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_tm_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO number_sequences (name, next_value) VALUES ('ticket', 1000) ON DUPLICATE KEY UPDATE name = name;