Support org
All checks were successful
Build and Push / build (push) Successful in 1m30s

This commit is contained in:
2026-05-02 10:50:06 +02:00
parent b023c068eb
commit 8273d08f15
19 changed files with 1974 additions and 5 deletions

View File

@@ -0,0 +1,103 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations, getFormatter } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
getSupportTicketById,
listCommentsForTicket,
} from "@/lib/db";
import { Card } from "@/components/ui/card";
import { BackLink } from "@/components/ui/back-link";
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
import { TicketThread } from "@/components/support/ticket-thread";
import { TicketAdminControls } from "@/components/support/ticket-admin-controls";
import { formatDateTime } from "@/lib/format";
/**
* /support/[id] — single ticket detail.
*
* Same UI for customer and admin; admin gets an extra
* `<TicketAdminControls>` block for changing status/category. The
* customer side gets a "Close ticket" link if they want to mark it
* resolved themselves.
*
* Authorization mirrors the API: customer sees their own; platform
* admin sees any. 404 (not 403) when a customer accesses someone
* else's ticket — don't leak existence.
*/
export default async function TicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
const { id } = await params;
const ticket = await getSupportTicketById(id);
if (!ticket) notFound();
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
notFound();
}
const comments = await listCommentsForTicket(id);
const t = await getTranslations("support");
const f = await getFormatter();
return (
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="mb-6 animate-in">
<BackLink href="/support" label={t("title")} />
<div className="flex items-start justify-between gap-3 mt-2">
<h1 className="font-display text-2xl font-semibold">
{ticket.title}
</h1>
<TicketStatusBadge status={ticket.status} />
</div>
<div className="text-xs text-text-muted mt-2 flex items-center gap-2 flex-wrap">
<TicketCategoryLabel category={ticket.category} />
<span>·</span>
<span>
{t("openedBy", {
name: ticket.contactName,
when: formatDateTime(ticket.createdAt, f),
})}
</span>
<span>·</span>
<span className="font-mono">#{ticket.id.slice(0, 8)}</span>
</div>
</div>
{/* Original ticket description, rendered as the first message
in the thread. Visually distinct via the customer-author
styling (handled inside <TicketThread>). */}
<div className="space-y-4 animate-in animate-in-delay-1">
<Card>
<div className="flex items-center justify-between text-xs text-text-muted mb-2">
<span className="font-medium text-text-primary">
{ticket.contactName}
</span>
<span>{formatDateTime(ticket.createdAt, f)}</span>
</div>
<div className="text-sm text-text-primary whitespace-pre-wrap">
{ticket.description}
</div>
</Card>
<TicketThread
ticketId={ticket.id}
ticketStatus={ticket.status}
comments={comments}
isPlatform={user.isPlatform}
isOwnTicket={ticket.zitadelUserId === user.id}
/>
{user.isPlatform && (
<TicketAdminControls
ticketId={ticket.id}
currentStatus={ticket.status}
currentCategory={ticket.category}
/>
)}
</div>
</main>
);
}

View File

@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { TicketCreateForm } from "@/components/support/ticket-create-form";
import { BackLink } from "@/components/ui/back-link";
/**
* /support/new — create ticket form.
*
* Platform admins shouldn't open tickets via this UI (they'd be
* opening one as if from a customer, which is confusing). Redirect
* them back to the queue. Non-admins of any role can create.
*/
export default async function NewTicketPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/support");
const t = await getTranslations("support");
return (
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<BackLink href="/support" label={t("title")} />
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("newTicketTitle")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("newTicketSubtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<TicketCreateForm />
</div>
</main>
);
}

View File

@@ -0,0 +1,97 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getTranslations, getFormatter } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
listSupportTicketsForUser,
listAllSupportTickets,
} from "@/lib/db";
import { Card } from "@/components/ui/card";
import { formatRelative } from "@/lib/format";
import { TicketStatusBadge } from "@/components/support/ticket-status-badge";
import { TicketCategoryLabel } from "@/components/support/ticket-category-label";
/**
* /support — ticket list.
*
* Customers see their own tickets only (per Feature 5: per-user
* scope, NOT per-org). Platform admins see the global queue. Same
* UI shell, different list source — the rendering logic is
* identical because the per-row data is the same shape.
*
* Sorting: newest activity first (the DB query already orders by
* updated_at DESC). Open tickets bubble to the top by virtue of
* having recent activity, but we don't sort by status; that's a
* filter the admin can add later if the queue grows.
*/
export default async function SupportListPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("support");
const f = await getFormatter();
const tickets = user.isPlatform
? await listAllSupportTickets()
: await listSupportTicketsForUser(user.id);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in flex items-end justify-between">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule">
{user.isPlatform ? t("titleAdmin") : t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{user.isPlatform ? t("subtitleAdmin") : t("subtitle")}
</p>
</div>
{!user.isPlatform && (
<Link
href="/support/new"
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
>
{t("newTicket")}
</Link>
)}
</div>
{tickets.length === 0 ? (
<Card className="animate-in animate-in-delay-1">
<p className="text-sm text-text-secondary text-center py-6">
{user.isPlatform ? t("emptyAdmin") : t("empty")}
</p>
</Card>
) : (
<div className="space-y-2 animate-in animate-in-delay-1">
{tickets.map((tk) => (
<Link
key={tk.id}
href={`/support/${tk.id}`}
className="block rounded-xl border border-border bg-surface-1 p-4 hover:border-text-secondary transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-text-primary truncate">
{tk.title}
</div>
<div className="text-xs text-text-muted mt-1 flex items-center gap-2">
<TicketCategoryLabel category={tk.category} />
<span>·</span>
<span>{formatRelative(tk.updatedAt, f)}</span>
{user.isPlatform && (
<>
<span>·</span>
<span className="font-mono">{tk.contactEmail}</span>
</>
)}
</div>
</div>
<TicketStatusBadge status={tk.status} />
</div>
</Link>
))}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
getSupportTicketById,
createSupportTicketComment,
updateSupportTicket,
} from "@/lib/db";
import {
sendSupportTicketReplyEmail,
sendSupportAdminNotificationEmail,
} from "@/lib/email";
import { safeError } from "@/lib/errors";
import type { SupportTicketStatus } from "@/types";
/**
* Comments on a support ticket (Feature 5). Threaded chronologically;
* no nested replies.
*
* Auto status transitions on comment:
* - Customer reply on a `waiting_for_customer` → `in_progress`
* (the ball is back in admin's court).
* - Customer reply on a `resolved` ticket → `reopened`
* (customer disagreed with the resolution).
* - Admin reply on `open` or `reopened` → `in_progress`
* (signals admin has engaged).
* - Admin reply on `in_progress` → `waiting_for_customer`
* (admin's response, ball is in customer's court).
* - Otherwise no change.
*
* The auto-bump is opportunistic — caller may pass an explicit
* status override via the PATCH endpoint instead. We only auto-bump
* here when no comment-side override is provided (the comment POST
* doesn't accept a status field).
*
* Email rules:
* - Customer replies → admin queue gets an "admin notification" email.
* - Admin replies → customer gets a reply email (with the body
* inline so they can read on mobile without clicking).
* - No "you just commented" confirmation back to the author.
*
* The customer reply path skips the separate status-change email
* even when the status auto-bumps, on the principle that one email
* per action is enough; the admin will see the reply notification
* and the new status in the queue.
*/
const createSchema = z.object({
body: z.string().trim().min(1, "required").max(10_000),
});
/**
* Compute the auto-bumped status (if any) for a comment from a given
* author kind. Returns the new status if it should change, or null
* if it should stay the same.
*/
function autoBumpStatus(
current: SupportTicketStatus,
authorKind: "customer" | "admin"
): SupportTicketStatus | null {
if (authorKind === "customer") {
if (current === "waiting_for_customer") return "in_progress";
if (current === "resolved") return "reopened";
return null;
}
// admin
if (current === "open" || current === "reopened") return "in_progress";
if (current === "in_progress") return "waiting_for_customer";
return null;
}
export async function POST(
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 });
}
// Same authorization as the GET on the parent resource.
if (!user.isPlatform && ticket.zitadelUserId !== user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
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 }
);
}
const authorKind: "customer" | "admin" = user.isPlatform
? "admin"
: "customer";
try {
const comment = await createSupportTicketComment({
ticketId: id,
authorUserId: user.id,
authorName: user.name,
authorKind,
body: parsed.data.body,
});
// Auto-bump status if the comment changes the ball's court.
const nextStatus = autoBumpStatus(ticket.status, authorKind);
if (nextStatus) {
await updateSupportTicket(id, { status: nextStatus });
}
// Email the other side. Customer's reply → admin queue;
// admin's reply → customer.
if (authorKind === "customer") {
sendSupportAdminNotificationEmail({
reason: "replied",
ticketId: ticket.id,
title: ticket.title,
contactName: ticket.contactName,
contactEmail: ticket.contactEmail,
body: parsed.data.body,
category: ticket.category,
}).catch((e) => console.error("admin notification:", e));
} else {
sendSupportTicketReplyEmail({
to: ticket.contactEmail,
contactName: ticket.contactName,
ticketId: ticket.id,
title: ticket.title,
authorName: user.name,
body: parsed.data.body,
}).catch((e) => console.error("reply email:", e));
}
return NextResponse.json({ comment }, { status: 201 });
} catch (e: any) {
console.error("Failed to create support ticket comment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to add comment") },
{ status: 500 }
);
}
}

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

View File

@@ -0,0 +1,103 @@
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 }
);
}
}