diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index df6b695..a1c052e 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -19,11 +19,14 @@ async function nextNumber(c: PoolConnection): Promise { 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, 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; }); diff --git a/apps/api/test/tickets.test.ts b/apps/api/test/tickets.test.ts index 45f5505..16702ce 100644 --- a/apps/api/test/tickets.test.ts +++ b/apps/api/test/tickets.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/app/(app)/tickets/[id]/page.tsx b/apps/web/src/app/(app)/tickets/[id]/page.tsx index e4ff3e9..c8879dd 100644 --- a/apps/web/src/app/(app)/tickets/[id]/page.tsx +++ b/apps/web/src/app/(app)/tickets/[id]/page.tsx @@ -19,7 +19,7 @@ function AttachmentList({ ticketId, atts }: { ticketId: string; atts: Attachment ); } 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 Detail { id: string; number: string; orgId: string; orgName: string; subject: string; status: string; priority: string; assignedTo: string | null; assignedName: string | null; resourceId: string | null; resourceName: string | null; resourceType: string | null; domainRecordId: string | null; domain: 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' }; @@ -50,6 +50,7 @@ export default function TicketDetail() { return (<>

{t.number}: {t.subject}

← Tickets

{staff && <>{t.orgName} · } · Priorität: {TICKET_PRIORITY[t.priority]}{t.assignedName && <> · zugewiesen an {t.assignedName}}

+ {(t.domain || t.resourceName) &&

Betrifft: {t.resourceId ? {t.resourceName} : staff ? {t.domain} : t.domain}

} {err && {err}}{ok && {ok}} {write &&
diff --git a/apps/web/src/app/(app)/tickets/page.tsx b/apps/web/src/app/(app)/tickets/page.tsx index fdba2c8..638b1cc 100644 --- a/apps/web/src/app/(app)/tickets/page.tsx +++ b/apps/web/src/app/(app)/tickets/page.tsx @@ -1,33 +1,42 @@ 'use client'; import { useCallback, useEffect, useState, type FormEvent } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; 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 T { id: string; number: string; orgId: string; orgName: string; customerNumber: string; subject: string; domain: string | null; resourceName: string | null; status: string; priority: string; assignedName: string | null; lastMessageAt: string } interface Cust { id: string; name: string; customerNumber: string } +interface Res { id: string; name: string; type: string } +interface Dom { id: string; domain: string } const PRIO_CLS: Record = { 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'); + const { me, can } = useSession(); const r = useRouter(); const sp = useSearchParams(); const staff = can('tickets.read'); const [list, setList] = useState(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([]); const [org, setOrg] = useState(''); const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(sp.get('new') === '1'); const [custs, setCusts] = useState([]); const [org, setOrg] = useState(sp.get('org') ?? ''); const [busy, setBusy] = useState(false); + const [resources, setResources] = useState([]); const [domains, setDomains] = useState([]); const [link, setLink] = useState(sp.get('domain') ? `dom:${sp.get('domain')}` : ''); const load = useCallback(() => api('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('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch(() => undefined); + if (staff) api('GET', '/admin/customers').then((l) => { setCusts(l); setOrg((cur) => cur || l[0]?.id || ''); }).catch(() => undefined); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]); + useEffect(() => { + if (!org) { setResources([]); setDomains([]); return; } + api('GET', `/resources?org=${org}`).then(setResources).catch(() => setResources([])); + if (staff) api('GET', `/admin/domain-records?orgId=${org}`).then(setDomains).catch(() => setDomains([])); else setDomains([]); + }, [org, staff]); async function create(e: FormEvent) { 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); + const [kind, linkId] = link.split(':'); try { - 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') } : {}) }); + 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') } : {}), ...(kind === 'res' ? { resourceId: linkId } : kind === 'dom' ? { domainRecordId: linkId } : {}) }); 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,7 +45,12 @@ export default function Tickets() {

Tickets

{err && {err}} {open &&

Neues Ticket

- {staff && } + {staff && } + {(resources.length > 0 || domains.length > 0) && }