133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|