Support-Tickets: Modul, Oberfläche, Discord-Benachrichtigung
This commit is contained in:
parent
fd34d121b1
commit
ca5b48ab47
12 changed files with 340 additions and 5 deletions
|
|
@ -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];
|
||||
|
|
|
|||
132
apps/api/src/modules/tickets/index.ts
Normal file
132
apps/api/src/modules/tickets/index.ts
Normal 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 };
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue