import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getSessionUser } from "@/lib/session"; import { getSupportTicketById, listCommentsForTicket, updateSupportTicket, } from "@/lib/db"; import { sendSupportTicketStatusEmail } from "@/lib/email"; import { safeError } from "@/lib/errors"; /** * Single support ticket detail (Feature 5). * * GET — returns the ticket plus all comments in chronological order. * Authorization: customer sees their own; platform admin sees any. * * PATCH — change status and/or category. Admin only. Sends a status * change email to the customer if status changed, UNLESS the same * call also creates a comment (in that case the comment endpoint * handles the email so the customer doesn't get two messages). * * No DELETE — tickets are durable history. Resolved tickets stay in * the DB for the audit trail. */ const patchSchema = z.object({ status: z .enum(["open", "in_progress", "waiting_for_customer", "resolved", "reopened"]) .optional(), category: z .enum(["bug", "feature_request", "question", "billing", "other"]) .optional(), }); export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { id } = await params; const ticket = await getSupportTicketById(id); if (!ticket) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Authorization: customer can see their own; platform admin can // see any. Owners cannot see their org's tickets — confirmed by // Feature 5 visibility design (per-user, not per-org). if (!user.isPlatform && ticket.zitadelUserId !== user.id) { // Don't leak existence — same 404 as if the ticket didn't exist. return NextResponse.json({ error: "Not found" }, { status: 404 }); } const comments = await listCommentsForTicket(id); return NextResponse.json({ ticket, comments }); } export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { id } = await params; const ticket = await getSupportTicketById(id); if (!ticket) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const body = await req.json().catch(() => null); const parsed = patchSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } // Authorization: status/category changes are admin-only EXCEPT // the customer can close their own ticket via status='resolved' // (Feature 5 design — gives them an "I figured it out, never mind" // escape hatch). Customer cannot reopen via this endpoint — that // happens automatically when they comment on a resolved ticket // (handled in the comments POST). const isCustomerSelfClose = !user.isPlatform && ticket.zitadelUserId === user.id && parsed.data.status === "resolved" && parsed.data.category === undefined; if (!user.isPlatform && !isCustomerSelfClose) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } try { const previousStatus = ticket.status; const updated = await updateSupportTicket(id, parsed.data); if (!updated) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Email customer when admin (not the customer themselves) // changes status. Skip on customer-self-close — they know what // they did. Skip when status didn't actually change (admin // edited only category). if ( user.isPlatform && parsed.data.status !== undefined && parsed.data.status !== previousStatus ) { sendSupportTicketStatusEmail({ to: ticket.contactEmail, contactName: ticket.contactName, ticketId: ticket.id, title: ticket.title, newStatus: parsed.data.status, }).catch((e) => console.error("status email:", e)); } return NextResponse.json({ ticket: updated }); } catch (e: any) { console.error("Failed to update support ticket:", e); return NextResponse.json( { error: safeError(e, "Failed to update ticket") }, { status: 500 } ); } }