Files
pieced-portal/src/app/api/support/tickets/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

104 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
createSupportTicket,
listSupportTicketsForUser,
listAllSupportTickets,
} from "@/lib/db";
import {
sendSupportTicketCreatedEmail,
sendSupportAdminNotificationEmail,
} from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* Support tickets API (Feature 5).
*
* Visibility: tickets are scoped strictly per-user (zitadel_user_id).
* Coworkers in the same org cannot see each other's tickets — this
* is the team's design choice for privacy. Platform admins see
* everything (the admin queue lives at the same UI but pulls from
* a different list).
*
* GET — for platform users, returns all tickets across all users.
* For everyone else, returns only the caller's own tickets. The
* client decides the rendering based on user role; we just return
* the right list.
*
* POST — creates a ticket, sends a confirmation email to the
* customer and a notification email to the admin distribution list.
*/
const createSchema = z.object({
title: z.string().trim().min(3, "required").max(200),
description: z.string().trim().min(10, "required").max(10_000),
category: z.enum(["bug", "feature_request", "question", "billing", "other"]),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Platform admins get the global queue; everyone else sees their
// own tickets only. Visibility-by-default-deny: even an org owner
// doesn't see their coworkers' tickets, by Feature 5 design.
const tickets = user.isPlatform
? await listAllSupportTickets()
: await listSupportTicketsForUser(user.id);
return NextResponse.json({ tickets });
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const ticket = await createSupportTicket({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
title: parsed.data.title,
description: parsed.data.description,
category: parsed.data.category,
contactName: user.name,
contactEmail: user.email,
});
// Fire-and-log email notifications. Both are best-effort;
// failure to send doesn't roll back the ticket creation.
sendSupportTicketCreatedEmail({
to: user.email,
contactName: user.name,
ticketId: ticket.id,
title: ticket.title,
}).catch((e) => console.error("ticket created email:", e));
sendSupportAdminNotificationEmail({
reason: "created",
ticketId: ticket.id,
title: ticket.title,
contactName: user.name,
contactEmail: user.email,
body: ticket.description,
category: ticket.category,
}).catch((e) => console.error("admin notification:", e));
return NextResponse.json({ ticket }, { status: 201 });
} catch (e: any) {
console.error("Failed to create support ticket:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to create ticket") },
{ status: 500 }
);
}
}