Ticket-Anhänge: Bilder/PDF hochladen und anzeigen (geprüft anhand der Dateikennung)

This commit is contained in:
Kundencenter 2026-09-27 08:53:27 +02:00
parent 236c76b4e2
commit 012f0aa0db
12 changed files with 254 additions and 22 deletions

View file

@ -12,7 +12,7 @@ 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`).
Support-Tickets (je Kunde, mit internen Notizen für Personal, Discord-Benachrichtigung).
Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung).
Noch **nicht** vorhanden: Rechnungen/Zahlungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung).
## Modularität

View file

@ -15,6 +15,7 @@
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/helmet": "^13.1.1",
"@fastify/multipart": "^10.1.2",
"@fastify/rate-limit": "^11.2.0",
"@kc/connector-sdk": "workspace:*",
"@kc/connectors": "workspace:*",

View file

@ -0,0 +1,58 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rename, unlink } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
export const STORE_ROOT = process.env.TICKET_ATTACHMENT_DIR || '/var/lib/kundencenter/ticket-attachments';
export const MAX_FILE_BYTES = 15 * 1024 * 1024;
export const MAX_FILES_PER_MESSAGE = 5;
const EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf' };
/** Erkennt den Dateityp anhand der ersten Bytes (nicht anhand des vom Client behaupteten Typs). Nur Bilder und PDF sind erlaubt. */
export function sniff(head: Buffer): string | null {
if (head.length >= 8 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47) return 'image/png';
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
if (head.length >= 6 && head.toString('ascii', 0, 6) === 'GIF87a') return 'image/gif';
if (head.length >= 6 && head.toString('ascii', 0, 6) === 'GIF89a') return 'image/gif';
if (head.length >= 12 && head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
if (head.length >= 5 && head.toString('ascii', 0, 5) === '%PDF-') return 'application/pdf';
return null;
}
export function pathFor(ticketId: string, attachmentId: string, contentType: string): string {
return join(STORE_ROOT, ticketId, `${attachmentId}.${EXT[contentType] ?? 'bin'}`);
}
/** Schreibt einen Multipart-Dateistrom auf die Platte, prüft dabei Größe und Dateikennung anhand der ersten Bytes.
* Wirft bei Überschreitung/unbekanntem Typ, ohne einen Rest löschen zu müssen (nichts wird vorher persistiert). */
export async function storeUpload(ticketId: string, stream: NodeJS.ReadableStream): Promise<{ id: string; contentType: string; sizeBytes: number; finalPath: string }> {
const id = randomUUID();
const dir = join(STORE_ROOT, ticketId);
await mkdir(dir, { recursive: true });
const tmpPath = join(dir, `.tmp-${id}`);
let head = Buffer.alloc(0);
let size = 0;
const collector = async function* (src: NodeJS.ReadableStream) {
for await (const chunkUnknown of src as AsyncIterable<Buffer>) {
const chunk = chunkUnknown as Buffer;
size += chunk.length;
if (size > MAX_FILE_BYTES) throw new Error('TOO_LARGE');
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
yield chunk;
}
};
try {
await pipeline(collector(stream), createWriteStream(tmpPath));
} catch (e) {
await unlink(tmpPath).catch(() => undefined);
throw e;
}
const contentType = sniff(head);
if (!contentType) { await unlink(tmpPath).catch(() => undefined); throw new Error('UNSUPPORTED_TYPE'); }
const finalPath = pathFor(ticketId, id, contentType);
await rename(tmpPath, finalPath);
return { id, contentType, sizeBytes: size, finalPath };
}

View file

@ -1,14 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import type { PoolConnection } from 'mysql2/promise';
import multipart from '@fastify/multipart';
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 { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
import { MAX_FILES_PER_MESSAGE, MAX_FILE_BYTES, pathFor, storeUpload } from './files.js';
const OPEN = new Set(['open', 'pending_customer', 'pending_staff']);
async function nextNumber(c: PoolConnection): Promise<string> {
@ -21,7 +24,8 @@ const ticketView = (t: any) => ({
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 messageView = (m: any, atts: 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,
attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) });
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. */
@ -30,7 +34,8 @@ async function loadTicket(id: string, forCustomer: boolean) {
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 };
const atts = msgs.length ? await query(`SELECT * FROM ticket_attachments WHERE message_id IN (${msgs.map(() => '?').join(',')})`, msgs.map((m) => m.id)) : [];
return { t, msgs, atts };
}
export const ticketsModule: KcModule = {
@ -47,17 +52,17 @@ export const ticketsModule: KcModule = {
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 id = randomUUID(); const messageId = 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);
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [messageId, 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 };
return { id, number, messageId };
});
app.get('/tickets', async (req) => {
@ -82,7 +87,7 @@ export const ticketsModule: KcModule = {
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') };
return { ...ticketView(res.t), messages: res.msgs.map((m) => messageView(m, res.atts)), canWrite: staff || canInOrg(a.principal, res.t.org_id, 'tickets.create', 'tickets.write') };
});
app.post('/tickets/:id/messages', async (req) => {
@ -94,13 +99,54 @@ export const ticketsModule: KcModule = {
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';
const messageId = randomUUID();
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('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,?)', [messageId, 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 };
return { ok: true, messageId };
});
// ---- Dateianhänge (Bilder, PDF) zu einer eigenen Nachricht --------------
// "files" bewusst höher als MAX_FILES_PER_MESSAGE: die genaue Zählung (inkl. bereits vorhandener
// Anhänge) übernimmt die Schleife unten, mit einer verständlichen Fehlermeldung statt eines rohen Busboy-Fehlers.
app.register(multipart, { limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES_PER_MESSAGE + 10 } });
app.post('/tickets/:id/messages/:messageId/attachments', async (req) => {
const a = requireAuth(req); const { id, messageId } = z.object({ id: z.string().uuid(), messageId: z.string().uuid() }).parse(req.params);
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();
const msg = await one('SELECT * FROM ticket_messages WHERE id = ? AND ticket_id = ?', [messageId, id]); if (!msg) throw notFound();
if (msg.author_id !== a.user.id && !staff) throw forbidden('Anhänge können nur zur eigenen Nachricht hinzugefügt werden', 'NOT_MESSAGE_AUTHOR');
if (t.status === 'closed') throw conflict('Das Ticket ist geschlossen.', 'TICKET_CLOSED');
const already = Number((await one('SELECT COUNT(*) AS n FROM ticket_attachments WHERE message_id = ?', [messageId]))!.n);
const saved: { id: string; filename: string; contentType: string; sizeBytes: number }[] = [];
for await (const part of req.parts()) {
if (part.type !== 'file') continue;
if (already + saved.length >= MAX_FILES_PER_MESSAGE) { part.file.resume(); throw badRequest(`Höchstens ${MAX_FILES_PER_MESSAGE} Dateien je Nachricht`, 'TOO_MANY_FILES'); }
let up;
try { up = await storeUpload(id, part.file); }
catch (e) { if ((e as Error).message === 'TOO_LARGE') throw new AppError(413, 'FILE_TOO_LARGE', `Datei ist größer als ${Math.round(MAX_FILE_BYTES / 1024 / 1024)} MB`); if ((e as Error).message === 'UNSUPPORTED_TYPE') throw new AppError(415, 'UNSUPPORTED_TYPE', 'Nur Bilder (PNG/JPEG/GIF/WEBP) und PDF sind als Anhang erlaubt'); throw e; }
const filename = (part.filename || 'datei').slice(0, 200);
await run('INSERT INTO ticket_attachments (id, ticket_id, message_id, filename, content_type, size_bytes, uploaded_by) VALUES (?,?,?,?,?,?,?)',
[up.id, id, messageId, filename, up.contentType, up.sizeBytes, a.user.id]);
saved.push({ id: up.id, filename, contentType: up.contentType, sizeBytes: up.sizeBytes });
}
if (saved.length === 0) throw badRequest('Keine Datei übermittelt', 'NO_FILE');
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.attachment.add', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { messageId, files: saved.map((s) => s.filename) } });
return { attachments: saved };
});
app.get('/tickets/:id/attachments/:attachmentId', async (req, reply) => {
const a = requireAuth(req); const { id, attachmentId } = z.object({ id: z.string().uuid(), attachmentId: z.string().uuid() }).parse(req.params);
const staff = can(a.principal, 'tickets.read');
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t || !canInOrg(a.principal, t.org_id, 'tickets.read', 'tickets.read')) throw notFound();
const att = await one('SELECT ta.*, m.internal_note FROM ticket_attachments ta JOIN ticket_messages m ON m.id = ta.message_id WHERE ta.id = ? AND ta.ticket_id = ?', [attachmentId, id]);
if (!att || (att.internal_note && !staff)) throw notFound();
reply.header('content-type', att.content_type).header('content-disposition', `inline; filename="${encodeURIComponent(att.filename)}"`).header('cache-control', 'private, max-age=3600');
return reply.send(createReadStream(pathFor(id, att.id, att.content_type)));
});
app.patch('/tickets/:id', async (req) => {

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', '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'];
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', '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

@ -1,7 +1,8 @@
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';
import { randomBytes } from 'node:crypto';
import { call, code, login, makeUser, type Client } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
@ -55,3 +56,58 @@ describe('Support-Tickets', () => {
expect((await call(app, support, 'GET', '/tickets?status=closed')).json().map((t: any) => t.id)).toEqual([id]);
});
});
/** Baut einen multipart/form-data-Body von Hand (ohne Bibliothek), ein Feld "file" je Eintrag. */
function multipart(files: { filename: string; mimetype: string; data: Buffer }[]): { body: Buffer; contentType: string } {
const boundary = `----kc-test-${randomBytes(8).toString('hex')}`;
const parts = files.map((f) => Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${f.filename}"\r\nContent-Type: ${f.mimetype}\r\n\r\n`),
f.data, Buffer.from('\r\n'),
]));
return { body: Buffer.concat([...parts, Buffer.from(`--${boundary}--\r\n`)]), contentType: `multipart/form-data; boundary=${boundary}` };
}
function upload(client: Client, url: string, files: { filename: string; mimetype: string; data: Buffer }[]) {
const { body, contentType } = multipart(files);
return app.inject({ method: 'POST', url: `/v1${url}`, payload: body, headers: { cookie: client.cookie, 'x-csrf-token': client.csrf, 'content-type': contentType } });
}
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const PDF = Buffer.from('%PDF-1.4 minimal test file, kein echtes PDF nötig für den Test');
describe('Ticket-Anhänge', () => {
it('Bild/PDF werden anhand der Dateikennung geprüft, gespeichert und ausgeliefert; falscher Typ und fremde Nachricht werden abgelehnt', async () => {
const admin = await staff('att-admin@example.com', 'admin'); const support = await staff('att-support@example.com', 'support');
const A = await customer(admin, 'Firma A', 'att-a@example.com'); const B = await customer(admin, 'Firma B', 'att-b@example.com');
const t = (await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Screenshot anbei', body: 'siehe Anhang' })).json();
const msgId = (await call(app, A.client, 'GET', `/tickets/${t.id}`)).json().messages[0].id;
const bad = await upload(A.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'x.txt', mimetype: 'text/plain', data: Buffer.from('kein Bild') }]);
expect(bad.statusCode).toBe(415);
const okRes = await upload(A.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'schirm.png', mimetype: 'image/png', data: PNG }, { filename: 'beleg.pdf', mimetype: 'application/pdf', data: PDF }]);
expect(okRes.statusCode).toBe(200); const atts = okRes.json().attachments; expect(atts).toHaveLength(2); expect(atts[0].contentType).toBe('image/png');
// fremder Kunde darf weder die Nachricht noch den Anhang sehen; nicht Autor darf nichts anhängen
expect((await upload(B.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'x.png', mimetype: 'image/png', data: PNG }])).statusCode).toBe(404);
expect((await call(app, B.client, 'GET', `/tickets/${t.id}/attachments/${atts[0].id}`)).statusCode).toBe(404);
const dl = await call(app, A.client, 'GET', `/tickets/${t.id}/attachments/${atts[0].id}`);
expect(dl.statusCode).toBe(200); expect(dl.headers['content-type']).toBe('image/png'); expect(Buffer.compare(dl.rawPayload, PNG)).toBe(0);
const detail = (await call(app, support, 'GET', `/tickets/${t.id}`)).json();
expect(detail.messages[0].attachments.map((a: any) => a.filename).sort()).toEqual(['beleg.pdf', 'schirm.png']);
// interne Notiz mit Anhang bleibt für den Kunden unsichtbar
await call(app, support, 'POST', `/tickets/${t.id}/messages`, { body: 'interne Notiz', internalNote: true });
const noteMsgId = (await call(app, support, 'GET', `/tickets/${t.id}`)).json().messages.at(-1).id;
await upload(support, `/tickets/${t.id}/messages/${noteMsgId}/attachments`, [{ filename: 'intern.png', mimetype: 'image/png', data: PNG }]);
const custView = (await call(app, A.client, 'GET', `/tickets/${t.id}`)).json();
expect(custView.messages.some((m: any) => m.attachments.some((a: any) => a.filename === 'intern.png'))).toBe(false);
const internAttId = (await call(app, support, 'GET', `/tickets/${t.id}`)).json().messages.find((m: any) => m.internalNote).attachments[0].id;
expect((await call(app, A.client, 'GET', `/tickets/${t.id}/attachments/${internAttId}`)).statusCode).toBe(404);
expect((await call(app, support, 'GET', `/tickets/${t.id}/attachments/${internAttId}`)).statusCode).toBe(200);
// zu viele Dateien in einer Nachricht
const manyMsgId = (await call(app, A.client, 'POST', `/tickets/${t.id}/messages`, { body: 'noch mehr Bilder' })).json().messageId;
const many = Array.from({ length: 6 }, (_, i) => ({ filename: `b${i}.png`, mimetype: 'image/png', data: PNG }));
expect((await upload(A.client, `/tickets/${t.id}/messages/${manyMsgId}/attachments`, many)).statusCode).toBe(400);
});
});

View file

@ -1,2 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['test/global-setup.ts'], setupFiles: ['test/setup.ts'], env: { DB_NAME: 'kundencenter_test', KC_NODE_ENV: 'test', KC_BASE_URL: 'http://localhost:4101' }, fileParallelism: false, testTimeout: 30000 } });
export default defineConfig({ test: { globalSetup: ['test/global-setup.ts'], setupFiles: ['test/setup.ts'], env: { DB_NAME: 'kundencenter_test', KC_NODE_ENV: 'test', KC_BASE_URL: 'http://localhost:4101', TICKET_ATTACHMENT_DIR: '/tmp/kc-test-attachments' }, fileParallelism: false, testTimeout: 30000 } });

View file

@ -2,11 +2,23 @@
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 { api, apiUpload, 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 }
const MAX_FILES = 5;
const ALLOWED = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';
interface Attachment { id: string; filename: string; contentType: string; sizeBytes: number }
function AttachmentList({ ticketId, atts }: { ticketId: string; atts: Attachment[] }) {
if (atts.length === 0) return null;
return (<div className="row" style={{ marginTop: 8, flexWrap: 'wrap', gap: 8 }}>
{atts.map((a) => { const href = `/api/tickets/${ticketId}/attachments/${a.id}`; const kb = Math.max(1, Math.round(a.sizeBytes / 1024));
return a.contentType.startsWith('image/')
? <a key={a.id} href={href} target="_blank" rel="noreferrer" title={`${a.filename} (${kb} KB)`}><img src={href} alt={a.filename} style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, border: '1px solid var(--border)', display: 'block' }} /></a>
: <a key={a.id} className="btn small" href={href} target="_blank" rel="noreferrer">📄 {a.filename} <span className="muted small">({kb} KB)</span></a>; })}
</div>);
}
interface Msg { id: string; authorId: string; authorName: string; authorKind: 'customer' | 'staff'; body: string; internalNote: boolean; createdAt: string; attachments: Attachment[] }
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' };
@ -20,8 +32,15 @@ export default function TicketDetail() {
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); }
e.preventDefault(); setErr(''); setBusy(true); const f = e.currentTarget; const fd = new FormData(f);
const body = String(fd.get('body') ?? ''); const internal = fd.get('internal') === 'on';
const files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
try {
const { messageId } = await api<{ messageId: string }>('POST', `/tickets/${id}/messages`, { body, internalNote: internal });
f.reset();
if (files.length > 0) { try { await apiUpload(`/tickets/${id}/messages/${messageId}/attachments`, files); } catch (x) { setErr(`Nachricht gesendet, aber Anhang fehlgeschlagen: ${errMsg(x)}`); } }
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)); } }
@ -40,10 +59,12 @@ export default function TicketDetail() {
<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>))}
<p style={{ whiteSpace: 'pre-wrap', margin: '4px 0 0' }}>{m.body}</p>
<AttachmentList ticketId={t.id} atts={m.attachments} /></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>
<Field id="files" label="Anhänge (optional)" hint={`Bilder oder PDF, höchstens ${MAX_FILES} Dateien, je 15 MB.`}><input id="files" name="files" type="file" accept={ALLOWED} multiple /></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

@ -2,13 +2,14 @@
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 { api, apiUpload, 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' };
const MAX_FILES = 5; const ALLOWED = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';
export default function Tickets() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('tickets.read');
@ -23,9 +24,11 @@ export default function Tickets() {
}, [staff, me]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = new FormData(e.currentTarget);
e.preventDefault(); setErr(''); setBusy(true); const fd = new FormData(e.currentTarget);
const files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
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') } : {}) });
const t = await api<{ id: string; messageId: string }>('POST', '/tickets', { orgId: org, subject: fd.get('subject'), body: fd.get('body'), ...(staff && fd.get('priority') ? { priority: fd.get('priority') } : {}) });
if (files.length > 0) await apiUpload(`/tickets/${t.id}/messages/${t.messageId}/attachments`, files);
r.push(`/tickets/${t.id}`);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
@ -36,6 +39,7 @@ export default function Tickets() {
{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>
<Field id="files" label="Anhänge (optional)" hint={`Bilder oder PDF, höchstens ${MAX_FILES} Dateien, je 15 MB.`}><input id="files" name="files" type="file" accept={ALLOWED} multiple /></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">

View file

@ -11,3 +11,12 @@ export async function api<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'PATCH'
return data as T;
}
export const errMsg = (e: unknown): string => e instanceof ApiError ? (e.details?.length ? `${e.message}: ${e.details.map((d) => d.message).join(', ')}` : e.message) : 'Der Server ist nicht erreichbar. Bitte später erneut versuchen.';
/** Dateiupload (multipart/form-data). Kein content-type-Header setzen – der Browser ergänzt die Grenze selbst. */
export async function apiUpload<T = unknown>(path: string, files: File[], fieldName = 'file'): Promise<T> {
const fd = new FormData(); for (const f of files) fd.append(fieldName, f);
const res = await fetch(`/api${path}`, { method: 'POST', credentials: 'same-origin', headers: { ...(csrf ? { 'x-csrf-token': csrf } : {}) }, body: fd });
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'ERROR', data?.error?.message ?? 'Anfrage fehlgeschlagen', data?.error?.details);
return data as T;
}

View file

@ -0,0 +1,13 @@
-- Dateianhänge zu Ticket-Nachrichten (Bilder, PDF). Dateien liegen außerhalb der Datenbank unter
-- /var/lib/kundencenter/ticket-attachments/<id>, hier nur Metadaten + geprüfter Inhaltstyp.
CREATE TABLE ticket_attachments (
id CHAR(36) PRIMARY KEY,
ticket_id CHAR(36) NOT NULL REFERENCES tickets(id),
message_id CHAR(36) NOT NULL REFERENCES ticket_messages(id),
filename VARCHAR(200) NOT NULL, -- ursprünglicher Dateiname, nur zur Anzeige
content_type VARCHAR(100) NOT NULL, -- geprüft anhand der Dateikennung, nicht der Angabe des Browsers
size_bytes INT NOT NULL,
uploaded_by CHAR(36) NOT NULL REFERENCES users(id),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_ta_ticket (ticket_id), KEY idx_ta_message (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

24
pnpm-lock.yaml generated
View file

@ -16,6 +16,9 @@ importers:
'@fastify/helmet':
specifier: ^13.1.1
version: 13.1.1
'@fastify/multipart':
specifier: ^10.1.2
version: 10.1.2
'@fastify/rate-limit':
specifier: ^11.2.0
version: 11.2.0
@ -424,9 +427,15 @@ packages:
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
'@fastify/busboy@3.2.2':
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
'@fastify/cookie@11.1.2':
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
'@fastify/deepmerge@3.2.1':
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@ -442,6 +451,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/multipart@10.1.2':
resolution: {integrity: sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==}
'@fastify/proxy-addr@5.1.1':
resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==}
@ -1862,11 +1874,15 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.2.1
'@fastify/busboy@3.2.2': {}
'@fastify/cookie@11.1.2':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.0
'@fastify/deepmerge@3.2.1': {}
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.1.0':
@ -1884,6 +1900,14 @@ snapshots:
dependencies:
dequal: 2.0.3
'@fastify/multipart@10.1.2':
dependencies:
'@fastify/busboy': 3.2.2
'@fastify/deepmerge': 3.2.1
'@fastify/error': 4.2.0
fastify-plugin: 6.0.0
secure-json-parse: 4.1.0
'@fastify/proxy-addr@5.1.1':
dependencies:
'@fastify/forwarded': 3.0.2