Files
pieced-portal/src/app/api/support/tickets/[id]/route.ts
admin 8273d08f15
All checks were successful
Build and Push / build (push) Successful in 1m30s
Support org
2026-05-02 10:50:06 +02:00

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 }
);
}
}