import { NextResponse } from 'next/server' import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' const updateTicketSchema = z.object({ status: z.enum(['open', 'pending', 'resolved', 'closed']).optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), assignee: z.string().max(200).nullable().optional(), categoryId: z.string().max(50).optional(), subject: z.string().min(1).max(200).optional(), }) // PATCH /api/aos/tickets/[id] — update a ticket. // Side effects: // - status='resolved' → set slaResolvedAt (if not already set) // - status='closed' → set slaResolvedAt (if not already set) — close implies resolution // - First non-empty assignee → set slaRespondedAt (if not already set) — assignment counts as response // // Audit-logged (GoBD). export async function PATCH( req: Request, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params const parsed = updateTicketSchema.safeParse(await req.json().catch(() => ({}))) if (!parsed.success) { return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } const body = parsed.data const existing = await db.ticket.findUnique({ where: { id } }) if (!existing) { return NextResponse.json({ error: 'ticket not found' }, { status: 404 }) } const data: Record = {} for (const [k, v] of Object.entries(body)) { data[k] = v } const now = new Date() // SLA resolution timestamp. if ( (data.status === 'resolved' || data.status === 'closed') && !existing.slaResolvedAt ) { data.slaResolvedAt = now } // SLA response timestamp — assignment counts as first response. if ( typeof data.assignee === 'string' && data.assignee.trim().length > 0 && !existing.slaRespondedAt ) { data.slaRespondedAt = now } if (Object.keys(data).length === 0) { return NextResponse.json({ error: 'no fields to update' }, { status: 400 }) } const updated = await db.ticket.update({ where: { id }, data }) await db.auditLog.create({ data: { actor: 'admin:ui', action: 'ticket:update', entity: 'Ticket', entityId: id, before: JSON.stringify(existing), after: JSON.stringify(updated), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return NextResponse.json({ ticket: updated }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } } // DELETE /api/aos/tickets/[id] — delete a ticket. // // Cascade policy: tickets with messages cannot be hard-deleted (the // conversation is part of the audit trail). Tickets in status 'closed' or // 'resolved' with NO messages can be deleted. Tickets that have been // escalated (escalatedToAdmin=true) cannot be deleted. // // GoBD: never silently cascade-delete ticket messages. export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params const existing = await db.ticket.findUnique({ where: { id }, include: { messages: { select: { id: true } } }, }) if (!existing) { return NextResponse.json({ error: 'ticket not found' }, { status: 404 }) } // Block if escalated to admin — those records stay for compliance. if (existing.escalatedToAdmin) { return NextResponse.json( { error: 'ticket escalated to admin — cannot be deleted' }, { status: 409 } ) } // Block if ticket has messages — conversation must be retained. if (existing.messages.length > 0) { return NextResponse.json( { error: 'ticket has messages — close the ticket instead of deleting it', messageCount: existing.messages.length, }, { status: 409 } ) } // Only allow deletion of empty (no message) tickets in resolved/closed state. // (Open/pending tickets with no messages were created in error and may be cleaned up.) await db.ticket.delete({ where: { id } }) await db.auditLog.create({ data: { actor: 'admin:ui', action: 'ticket:delete', entity: 'Ticket', entityId: id, before: JSON.stringify(existing), after: JSON.stringify({ deleted: true }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return NextResponse.json({ ok: true, deleted: true, id }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } }