Tickets: Bezug zu Ressource oder Domain wählen, Verknüpfung direkt aus der Domain-Aufstellung
This commit is contained in:
parent
43dc5bc827
commit
a1472617b9
6 changed files with 62 additions and 18 deletions
|
|
@ -19,11 +19,14 @@ async function nextNumber(c: PoolConnection): Promise<string> {
|
|||
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,
|
||||
id: t.id, number: t.number, orgId: t.org_id, orgName: t.org_name, customerNumber: t.customer_number, subject: t.subject,
|
||||
resourceId: t.resource_id, resourceName: t.resource_name ?? null, resourceType: t.resource_type ?? null,
|
||||
domainRecordId: t.domain_record_id, domain: t.domain ?? null,
|
||||
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 TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS assigned_name, r.name AS resource_name, r.type AS resource_type, d.domain AS domain
|
||||
FROM tickets t JOIN organizations g ON g.id = t.org_id LEFT JOIN users u ON u.id = t.assigned_to LEFT JOIN resources r ON r.id = t.resource_id LEFT JOIN domain_records d ON d.id = t.domain_record_id`;
|
||||
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 });
|
||||
|
|
@ -47,16 +50,18 @@ export const ticketsModule: KcModule = {
|
|||
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 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(), domainRecordId: 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 && b.domainRecordId) throw badRequest('Ein Ticket kann sich nur auf eine Ressource oder eine Domain beziehen, nicht beides');
|
||||
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');
|
||||
if (b.domainRecordId && !(await one('SELECT 1 AS x FROM domain_records WHERE id = ? AND org_id = ?', [b.domainRecordId, b.orgId]))) throw badRequest('Domain gehört nicht zu diesem Kunden');
|
||||
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 tickets (id, number, org_id, resource_id, domain_record_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
[id, n, b.orgId, b.resourceId ?? null, b.domainRecordId ?? 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 (?,?,?,?,?)', [messageId, id, a.user.id, staff ? 'staff' : 'customer', b.body], c);
|
||||
return n;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,3 +111,22 @@ describe('Ticket-Anhänge', () => {
|
|||
expect((await upload(A.client, `/tickets/${t.id}/messages/${manyMsgId}/attachments`, many)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket-Bezug: Ressource oder Domain', () => {
|
||||
it('Ticket kann sich auf eine Ressource oder eine Domain beziehen (nie beides), fremde Zuordnung wird abgelehnt', async () => {
|
||||
const admin = await staff('link-admin@example.com', 'admin');
|
||||
const A = await customer(admin, 'Firma A', 'link-a@example.com'); const B = await customer(admin, 'Firma B', 'link-b@example.com');
|
||||
const dom = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-test.de', orgId: A.org })).json();
|
||||
const domB = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-testb.de', orgId: B.org })).json();
|
||||
|
||||
const tDom = (await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Domain zeigt Fehler', body: 'x', domainRecordId: dom.id })).json();
|
||||
const detail = (await call(app, A.client, 'GET', `/tickets/${tDom.id}`)).json();
|
||||
expect(detail.domainRecordId).toBe(dom.id); expect(detail.domain).toBe('link-test.de'); expect(detail.resourceId).toBeNull();
|
||||
|
||||
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'fremde Domain', body: 'x', domainRecordId: domB.id })).statusCode).toBe(400);
|
||||
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'beides', body: 'x', domainRecordId: dom.id, resourceId: dom.id })).statusCode).toBe(400);
|
||||
|
||||
const list = (await call(app, admin, 'GET', '/tickets?status=open')).json();
|
||||
expect(list.find((t: any) => t.id === tDom.id)?.domain).toBe('link-test.de');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue