Support Tickets
- Build a helpdesk system with tickets, priorities, assignments, SLA tracking, and canned responses
Prerequisite
Complete the Get Started guide first.
A support ticket system is what any team uses to track and resolve customer requests. Think SaaS support, IT helpdesks, internal ops teams, or any business that needs to triage incoming issues, assign them to people, and track resolution times.
By the end of this example you will have:
- Tickets — full CRUD with status workflows, priority levels, and category tagging
- Ticket replies — threaded conversation on each ticket with internal notes
- Canned responses — reusable reply templates for common questions
- Support dashboard — real stats showing open tickets, resolution times, and workload
- Realtime — all changes sync instantly across connected browsers
Database Schema and Permissions
Prompt
We are building a support ticket system on top of this template. Create
the database schema and add the permissions we need.
Database (via Supabase MCP):
Create four tables:
1. `tickets` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
ticket_number (serial, not null),
subject (text, not null),
description (text, not null),
status (text, check status in ('open', 'in_progress', 'waiting', 'resolved',
'closed'), default 'open', not null),
priority (text, check priority in ('low', 'normal', 'high', 'urgent'),
default 'normal', not null),
category (text, check category in ('bug', 'feature_request', 'question',
'billing', 'other'), default 'other', not null),
assigned_to (uuid, references profiles(id) on delete set null, nullable),
requester_email (text, nullable),
requester_name (text, nullable),
resolved_at (timestamptz, nullable),
first_response_at (timestamptz, nullable),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
Add a unique constraint on (team_id, ticket_number).
2. `ticket_replies` table — id (uuid, default gen_random_uuid(), primary key),
ticket_id (uuid, references tickets(id) on delete cascade, not null),
user_id (uuid, references profiles(id), not null),
content (text, not null),
is_internal (boolean, default false, not null),
created_at (timestamptz, default now()).
3. `canned_responses` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
title (text, not null),
content (text, not null),
category (text, nullable),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
4. `ticket_tags` table — id (uuid, default gen_random_uuid(), primary key),
ticket_id (uuid, references tickets(id) on delete cascade, not null),
tag (text, not null),
created_at (timestamptz, default now()).
Add a unique constraint on (ticket_id, tag).
Enable RLS on all four tables. Create policies that use the existing
`is_team_member()` function to scope access. For `tickets` and
`canned_responses`, the policy should check that the row's `team_id`
matches a team the user belongs to. For `ticket_replies` and `ticket_tags`,
join through the `tickets` table to check team membership.
All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks in the server
routes, not in RLS.
After creating the tables, add these permissions to `shared/permissions.ts`:
```
"tickets.view": ["owner", "admin", "member"],
"tickets.create": ["owner", "admin", "member"],
"tickets.update": ["owner", "admin", "member"],
"tickets.delete": ["owner", "admin"],
"tickets.assign": ["owner", "admin"],
"canned_responses.view": ["owner", "admin", "member"],
"canned_responses.manage": ["owner", "admin"],
```
Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.Adding plugins later?
If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.
Tickets
Prompt
Build the ticket management module — server routes and a full UI page.
Server routes (all use `authUser` with the appropriate permission):
- `GET /api/teams/[teamId]/tickets` — uses
`authUser(event, "tickets.view")`. Returns all tickets for the team with
the assignee name and reply count joined in. Order by created_at desc.
Support optional query params: `?status=` to filter by status,
`?priority=` to filter by priority, `?category=` to filter by category,
`?assigned_to=` to filter by assignee UUID, `?q=` to search by subject
or ticket_number (use ILIKE for subject).
- `POST /api/teams/[teamId]/tickets` — uses
`authUser(event, "tickets.create")`. Reads { subject, description, status,
priority, category, assigned_to, requester_email, requester_name, tags }
from the body. Validates that subject and description are required. Sets
team_id from the auth context and created_by from the authenticated user
(user.sub). If tags are provided (string array), insert them into
ticket_tags after creating the ticket.
- `PATCH /api/teams/[teamId]/tickets/[ticketId]` — uses
`authUser(event, "tickets.update")`. Updates whichever fields are
provided in the body. If status is being changed to "resolved",
automatically set resolved_at to now(). If assigned_to is being changed,
check that the caller has "tickets.assign" permission. Validates that
the ticket belongs to the team.
- `DELETE /api/teams/[teamId]/tickets/[ticketId]` — uses
`authUser(event, "tickets.delete")`. Validates that the ticket belongs to
the team before deleting.
- `GET /api/teams/[teamId]/tickets/[ticketId]/replies` — uses
`authUser(event, "tickets.view")`. Returns all replies for the ticket with
the author's name joined in, ordered by created_at asc.
- `POST /api/teams/[teamId]/tickets/[ticketId]/replies` — uses
`authUser(event, "tickets.view")`. Any team member can reply. Reads
{ content, is_internal } from the body. Sets user_id from the
authenticated user. If this is the first non-internal reply and the
ticket's first_response_at is null, set first_response_at to now().
UI:
Create `app/pages/tickets/index.vue` — a ticket list page as a top-level
route (not nested under `/dashboard`), wrapped in a `UDashboardPanel`:
- `UDashboardNavbar` in the header: title "Tickets",
`UDashboardSidebarCollapse` on the left. On the right, a "New Ticket"
button wrapped in `CanAccess permission="tickets.create"`.
- `UDashboardToolbar` below the navbar: on the left, show status counts
(e.g. "5 Open, 3 In Progress, 2 Waiting"). On the right, a search input
and status filter `USelect` with options: All, Open, In Progress,
Waiting, Resolved, Closed.
- Ticket list in the body as a table with columns: ticket number (formatted
as #001, #002, etc.), subject, category badge, requester name, assignee
name (or "Unassigned"), status badge (color-coded — open = "error",
in_progress = "warning", waiting = "info", resolved = "success",
closed = "neutral"), priority badge (low = "neutral", normal = "info",
high = "warning", urgent = "error"), reply count, and created date
(formatted as relative time, e.g. "2 hours ago").
- Each row has an inline status dropdown using `UDropdownMenu`. Show
per-row loading state while the status is being updated.
- "New Ticket" button opens a `UModal` with a form: subject (required),
description (required, textarea), category (select), priority (select),
assignee (optional — select from team members fetched on mount, use
placeholder "Unassigned"), requester name, requester email, tags
(comma-separated text input). Use Zod for validation. Show loading
on submit.
- Clicking a ticket row opens a `USlideover` for the ticket detail. Show
ticket metadata in a header section (subject, status, priority, category,
assignee, requester, tags, created date, resolved date). Below, show the
ticket description, then a "Conversation" section: a scrollable list of
replies (author name, content, timestamp). Internal notes should have a
distinct yellow background and an "Internal" badge. At the bottom, a
reply input with a "Reply" button and a toggle for "Internal note".
Show a canned response selector (`USelect`) that populates the reply
content when selected. Include action buttons: assign (opens a member
select), change priority, and delete (wrapped in
`CanAccess permission="tickets.delete"` with confirmation modal).
- After creating, editing, or deleting a ticket, refresh the ticket list.
Sidebar navigation:
Add a "Tickets" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-chat-round-dots-bold-duotone`.
The items array needs to become a `computed` (if it isn't already) so we
can conditionally include items based on permissions.Canned Responses
Prompt
Build the canned responses module — reusable reply templates.
Server routes:
- `GET /api/teams/[teamId]/canned-responses` — uses
`authUser(event, "canned_responses.view")`. Returns all canned responses
for the team, ordered by title asc.
- `POST /api/teams/[teamId]/canned-responses` — uses
`authUser(event, "canned_responses.manage")`. Reads { title, content,
category } from the body. Validates that title and content are required.
Sets team_id from auth context and created_by from the authenticated user.
- `PATCH /api/teams/[teamId]/canned-responses/[responseId]` — uses
`authUser(event, "canned_responses.manage")`. Updates whichever fields
are provided. Validates that the response belongs to the team.
- `DELETE /api/teams/[teamId]/canned-responses/[responseId]` — uses
`authUser(event, "canned_responses.manage")`. Validates that the response
belongs to the team before deleting.
UI:
Add a "Canned Responses" section to the team settings page (or create a
new settings sub-page at `app/pages/settings/canned-responses.vue`):
- Show a list of existing canned responses with title, category, and a
preview of the content (truncated to 100 characters).
- "New Response" button opens a `UModal` with a form: title (required),
content (required, textarea with markdown support), category (optional).
Use Zod for validation.
- Clicking a response opens it for editing in a `UModal`. Include a delete
button with confirmation.
- All management actions are wrapped in
`CanAccess permission="canned_responses.manage"`.Dashboard and Realtime
Prompt 1 — Dashboard
Replace the placeholder dashboard with real support metrics.
Server route:
Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:
- `open_tickets` — count of tickets with status "open" or "in_progress"
- `waiting_tickets` — count of tickets with status "waiting"
- `resolved_this_month` — count of tickets resolved in the current calendar
month (where resolved_at is in the current month)
- `avg_first_response_hours` — average hours between created_at and
first_response_at for tickets resolved this month. Return null if no data.
- `tickets_by_category` — array of { category, count } for open tickets,
ordered by count desc
- `tickets_by_priority` — array of { priority, count } for open tickets
- `recent_tickets` — the last 10 tickets with assignee name, status,
priority, and created_at. Include reply count.
- `agent_workload` — array of { agent_name, open_count, in_progress_count }
for each team member with assigned tickets, ordered by total count desc
All queries are scoped to the team's team_id.
UI:
Update `app/pages/dashboard/index.vue` to show:
- A row of four stat cards at the top using a grid layout. Each card shows
an icon, a label, and the value. Cards: "Open Tickets" (open_tickets),
"Waiting" (waiting_tickets), "Resolved This Month" (resolved_this_month),
"Avg First Response" (avg_first_response_hours formatted as "Xh" or
"< 1h", or "—" if null). Use `USkeleton` placeholders while loading.
- Below the stats, a two-column layout:
- Left column: "Recent Tickets" — a list of the last 10 tickets. Each
item shows ticket number, subject, status badge, priority badge,
assignee name, and relative timestamp. Clicking a ticket navigates to
the tickets page. Show an empty state if no tickets.
- Right column: "Team Workload" — a list of agents and their open/in-progress
ticket counts, shown as a simple bar or number. Below that, a breakdown
of tickets by category as small badges with counts.
Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.Prompt 2 — Realtime
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.
Database migration (via Supabase MCP):
Enable realtime publication and full replica identity for the new tables:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE tickets, ticket_replies, ticket_tags;
ALTER TABLE tickets REPLICA IDENTITY FULL;
ALTER TABLE ticket_replies REPLICA IDENTITY FULL;
ALTER TABLE ticket_tags REPLICA IDENTITY FULL;
```
Composables:
1. Create `app/composables/useRealtimeTickets.ts` — takes a
`Ref<Ticket[]>` (or whatever the ticket array type is). Registers on
the `tickets` table via the existing `useRealtime` composable's
`onTable` function. Handles INSERT (dedup against existing items by id),
UPDATE (replace the matching item), and DELETE (remove by id). Always
assign a new array to `.value` — do not mutate in place, or Vue will
not re-render.
2. Create `app/composables/useRealtimeReplies.ts` — same pattern but for
the `ticket_replies` table. Used inside the ticket detail slideover to
append new replies in realtime.
Update `app/composables/useRealtime.ts` to subscribe to `tickets`,
`ticket_replies`, and `ticket_tags` tables in addition to the existing
subscriptions.
Integration:
- In `app/pages/tickets/index.vue`, call
`useRealtimeTickets(tickets)` where `tickets` is the ref holding the
ticket list.
- In the ticket detail slideover, call `useRealtimeReplies(replies)` to
show new replies as they are posted by other agents.
- The dashboard stats page should refetch stats when any realtime event
fires on tickets or ticket_replies. Use `onTable("tickets", refetchStats)`
and `onTable("ticket_replies", refetchStats)` directly in the dashboard
page — no separate composable needed.What You Built
Starting from a template that handled auth, teams, roles, and permissions, you added:
- Tickets — a full helpdesk with status workflows, priorities, categories, and tags
- Ticket replies — threaded conversations with internal notes for team-only context
- Canned responses — reusable templates to speed up common replies
- Dashboard — support metrics, workload distribution, and response time tracking
- Realtime — all changes sync instantly across connected browsers
Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.
What's Next
- API Keys & External API — let external services create tickets or check status programmatically
- AI Chat — a chatbot that answers questions like "How many urgent tickets are open?" or "What's our average response time?"