diff --git a/src/app/[locale]/support/[id]/page.tsx b/src/app/[locale]/support/[id]/page.tsx new file mode 100644 index 0000000..334ff8e --- /dev/null +++ b/src/app/[locale]/support/[id]/page.tsx @@ -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 + * `` 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 ( +
+
+ +
+

+ {ticket.title} +

+ +
+
+ + · + + {t("openedBy", { + name: ticket.contactName, + when: formatDateTime(ticket.createdAt, f), + })} + + · + #{ticket.id.slice(0, 8)} +
+
+ + {/* Original ticket description, rendered as the first message + in the thread. Visually distinct via the customer-author + styling (handled inside ). */} +
+ +
+ + {ticket.contactName} + + {formatDateTime(ticket.createdAt, f)} +
+
+ {ticket.description} +
+
+ + + + {user.isPlatform && ( + + )} +
+
+ ); +} diff --git a/src/app/[locale]/support/new/page.tsx b/src/app/[locale]/support/new/page.tsx new file mode 100644 index 0000000..0633e8a --- /dev/null +++ b/src/app/[locale]/support/new/page.tsx @@ -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 ( +
+
+ +

+ {t("newTicketTitle")} +

+

+ {t("newTicketSubtitle")} +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/[locale]/support/page.tsx b/src/app/[locale]/support/page.tsx new file mode 100644 index 0000000..fc7d990 --- /dev/null +++ b/src/app/[locale]/support/page.tsx @@ -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 ( +
+
+
+

+ {user.isPlatform ? t("titleAdmin") : t("title")} +

+

+ {user.isPlatform ? t("subtitleAdmin") : t("subtitle")} +

+
+ {!user.isPlatform && ( + + {t("newTicket")} + + )} +
+ + {tickets.length === 0 ? ( + +

+ {user.isPlatform ? t("emptyAdmin") : t("empty")} +

+
+ ) : ( +
+ {tickets.map((tk) => ( + +
+
+
+ {tk.title} +
+
+ + · + {formatRelative(tk.updatedAt, f)} + {user.isPlatform && ( + <> + · + {tk.contactEmail} + + )} +
+
+ +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/api/support/tickets/[id]/comments/route.ts b/src/app/api/support/tickets/[id]/comments/route.ts new file mode 100644 index 0000000..646a547 --- /dev/null +++ b/src/app/api/support/tickets/[id]/comments/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/support/tickets/[id]/route.ts b/src/app/api/support/tickets/[id]/route.ts new file mode 100644 index 0000000..9bb2b68 --- /dev/null +++ b/src/app/api/support/tickets/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/support/tickets/route.ts b/src/app/api/support/tickets/route.ts new file mode 100644 index 0000000..47a5ab6 --- /dev/null +++ b/src/app/api/support/tickets/route.ts @@ -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 } + ); + } +} diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx index 70c116c..db615ac 100644 --- a/src/components/layout/nav-shell.tsx +++ b/src/components/layout/nav-shell.tsx @@ -74,6 +74,17 @@ function NavBar() { {t("settings")} )} + {/* Feature 5: Support is available to every signed-in + user. Customers see their own tickets only; platform + admins see the queue. */} + {user && ( + + {t("support")} + + )} {user?.isPlatform && ( {t("admin")} diff --git a/src/components/support/ticket-admin-controls.tsx b/src/components/support/ticket-admin-controls.tsx new file mode 100644 index 0000000..4d425c1 --- /dev/null +++ b/src/components/support/ticket-admin-controls.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { + SupportTicketCategory, + SupportTicketStatus, +} from "@/types"; + +const STATUSES: SupportTicketStatus[] = [ + "open", + "in_progress", + "waiting_for_customer", + "resolved", + "reopened", +]; +const CATEGORIES: SupportTicketCategory[] = [ + "bug", + "feature_request", + "question", + "billing", + "other", +]; + +interface Props { + ticketId: string; + currentStatus: SupportTicketStatus; + currentCategory: SupportTicketCategory; +} + +/** + * Admin-only controls — change ticket status / category. Visible + * exclusively when `user.isPlatform` (gate is in the parent server + * component, not here). + * + * Saves on dropdown change rather than via an explicit submit button + * — feels more like a queue-management panel than a form. Each save + * fires the email path (status change → status email to customer), + * so we deliberately don't auto-save category until the admin + * confirms; clicking through categories shouldn't spam status + * emails. (Status change emails the customer; category change does + * not — so category auto-save is fine. Status auto-save would also + * be fine in practice, but we keep an explicit save button on + * status to give admin a moment of pause before notifying.) + * + * In practice both fields auto-save — the email rule above is in + * the API anyway. If admin frustration with accidental status emails + * shows up in feedback, switch status to explicit-save. + */ +export function TicketAdminControls({ + ticketId, + currentStatus, + currentCategory, +}: Props) { + const t = useTranslations("support"); + const router = useRouter(); + + const [status, setStatus] = useState(currentStatus); + const [category, setCategory] = useState(currentCategory); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + const saveChange = async (changes: { + status?: SupportTicketStatus; + category?: SupportTicketCategory; + }) => { + setSaving(true); + setError(""); + try { + const res = await fetch( + `/api/support/tickets/${encodeURIComponent(ticketId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes), + } + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || t("updateFailed")); + } + router.refresh(); + } catch (e: any) { + setError(e.message); + // Revert local state on failure so the UI doesn't lie about + // what's saved. + if (changes.status) setStatus(currentStatus); + if (changes.category) setCategory(currentCategory); + } finally { + setSaving(false); + } + }; + + return ( + +
+ {t("adminControlsTitle")} +
+
+
+ + +
+
+ + +
+
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/src/components/support/ticket-category-label.tsx b/src/components/support/ticket-category-label.tsx new file mode 100644 index 0000000..8825d32 --- /dev/null +++ b/src/components/support/ticket-category-label.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { SupportTicketCategory } from "@/types"; + +/** + * Plain translated category label, e.g. "Bug" / "Feature request" / + * "Billing". No styling chrome — just the text. Categories don't + * carry the same lifecycle/urgency signal as status, so they don't + * earn a coloured pill. + */ +export function TicketCategoryLabel({ + category, +}: { + category: SupportTicketCategory; +}) { + const t = useTranslations("support"); + return {t(`category_${category}`)}; +} diff --git a/src/components/support/ticket-create-form.tsx b/src/components/support/ticket-create-form.tsx new file mode 100644 index 0000000..9806927 --- /dev/null +++ b/src/components/support/ticket-create-form.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { SupportTicketCategory } from "@/types"; + +const CATEGORIES: SupportTicketCategory[] = [ + "bug", + "feature_request", + "question", + "billing", + "other", +]; + +export function TicketCreateForm() { + const t = useTranslations("support"); + const tCommon = useTranslations("common"); + const router = useRouter(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState("question"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(""); + try { + const res = await fetch("/api/support/tickets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, description, category }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || t("createFailed")); + } + const data = await res.json(); + // Redirect to the new ticket's detail page so the customer can + // see the confirmation state and immediately add follow-ups if + // they wish. + router.push(`/support/${data.ticket.id}`); + router.refresh(); + } catch (e: any) { + setError(e.message); + setSubmitting(false); + } + }; + + return ( + +
+
+ + +
+ +
+ + setTitle(e.target.value)} + placeholder={t("titlePlaceholder")} + className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary" + /> +
+ +
+ +