This commit is contained in:
132
src/app/api/support/tickets/[id]/route.ts
Normal file
132
src/app/api/support/tickets/[id]/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user