List My Tickets
Owner-scoped paginated list of the current user's support tickets. Filter by status. Always returns YOUR tickets — no admin lens, no cross-user reads possible.
GET /api/v1/tickets — 🔑 Bearer
Returns a page of tickets owned by the current user (the bearer-token subject). Always filtered by userId = req.user.id server-side — there is no way to ask for someone else's tickets through this endpoint, regardless of query params.
No throttle on this endpoint. The class registers @UseGuards(ThrottleGuard) but only the ticket-create handler carries @Throttle(5, 60). The list / detail / reply / reopen handlers have no @Throttle() decorator, and ThrottleGuard.canActivate() returns true when no decorator is found. List as freely as your client needs to.
status filter takes one value. It's an enum string — OPEN, ASSIGNED, IN_PROGRESS, WAITING_USER, WAITING_INTERNAL, RESOLVED, or CLOSED. There's no IN(...) or comma-separated form. To show "active" tickets (not RESOLVED/CLOSED), omit the filter and partition client-side, or fire two parallel calls.
Request
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
page | integer (1-based) | 1 | Page number. Parsed via parseInt — ?page=abc becomes NaN, then defaults to 1. |
limit | integer | 20 | Items per page. Service caps at 100 (Math.min(limit, 100)). |
status | enum | — | One of: OPEN, ASSIGNED, IN_PROGRESS, WAITING_USER, WAITING_INTERNAL, RESOLVED, CLOSED. Single value only. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard enforces. |
No body.
Response
200 OK — PaginatedApiResponseOf<TicketListItemDto>
{
"success": true,
"data": {
"items": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "00000000-0000-4000-8000-000000000001",
"categoryId": "11111111-2222-3333-4444-555555555555",
"subject": "Payout delayed by 3 days",
"status": "WAITING_USER",
"priority": "HIGH",
"assignedTo": "agent-uuid",
"resolvedAt": null,
"closedAt": null,
"createdAt": "2026-04-20T09:00:00.000Z",
"updatedAt": "2026-04-22T14:30:00.000Z",
"category": {
"id": "11111111-2222-3333-4444-555555555555",
"name": "Payments",
"description": "Payment-related issues",
"priority": "HIGH",
"active": true,
"sortOrder": 1,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
}
],
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}Item fields (items[])
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Use as :ticketId for the detail/reply/reopen endpoints. |
userId | string (UUID) | Always equals the authenticated user's id. |
categoryId | string (UUID) | null | Optional category at create time. Cascades to null if the category is deleted. |
subject | string | 3-200 chars (validated on create). |
status | enum | See state machine below. |
priority | enum | LOW / MEDIUM / HIGH / URGENT. Defaults from category at create time, otherwise MEDIUM. |
assignedTo | string (UUID) | null | Agent currently assigned (admin-side action — user has no control). |
resolvedAt | string (ISO 8601) | null | Set when ticket transitions to RESOLVED. Cleared on reopen. |
closedAt | string (ISO 8601) | null | Set when ticket transitions to CLOSED. Cleared on reopen. |
createdAt / updatedAt | string (ISO 8601) | Standard timestamps. |
category | object | null | Embedded full TicketCategoryDto (when set). Always included; not categoryId-only. |
Pagination envelope
| Field | Type | Notes |
|---|---|---|
total | number | Total tickets matching the filter (for the current user). |
page / limit | number | Echo of the effective values (after defaulting + capping). |
totalPages | number | Math.ceil(total / limit). |
Errors
| HTTP | Reason |
|---|---|
401 | Missing / invalid bearer token. |
Status state machine
status is one of these states. Transitions are enforced server-side (see reopen):
| State | Set by | Next legal states |
|---|---|---|
OPEN | Ticket creation | ASSIGNED |
ASSIGNED | Admin assigns agent | IN_PROGRESS, RESOLVED, CLOSED |
IN_PROGRESS | Admin/agent reply, user reply on WAITING_USER | WAITING_USER, WAITING_INTERNAL, RESOLVED, CLOSED |
WAITING_USER | Agent reply | IN_PROGRESS (on user reply), RESOLVED, CLOSED |
WAITING_INTERNAL | Admin escalation | IN_PROGRESS, RESOLVED, CLOSED |
RESOLVED | Admin resolves | CLOSED, OPEN (via reopen) |
CLOSED | Admin closes | OPEN (via reopen) |
Side effects
- Decode bearer token (global
JwtAuthGuard);req.user.idis forwarded as@CurrentUser('id'). - Service builds
where = { userId }plus optionalstatus. TheuserIdclause is unconditional — even if the controller forwarded an empty body or weird query, the WHERE always filters to the current user. - Two parallel Prisma queries:
prisma.ticket.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take, include: { category: true } })prisma.ticket.count({ where })
- Compute
totalPages = Math.ceil(total / limit). Return the envelope.
No mutations. No notifications, no logs, no Redis touches.
Code samples
# All my tickets, page 1, 20 per page
curl https://api.bio.re/api/v1/tickets \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Only OPEN tickets
curl 'https://api.bio.re/api/v1/tickets?status=OPEN' \
-H "Authorization: Bearer $ACCESS_TOKEN"type TicketStatus =
| 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS'
| 'WAITING_USER' | 'WAITING_INTERNAL' | 'RESOLVED' | 'CLOSED';
type TicketListItem = {
id: string;
userId: string;
categoryId: string | null;
subject: string;
status: TicketStatus;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
assignedTo: string | null;
resolvedAt: string | null;
closedAt: string | null;
createdAt: string;
updatedAt: string;
category: { id: string; name: string; priority: string } | null;
};
type TicketListResponse = {
items: TicketListItem[];
total: number;
page: number;
limit: number;
totalPages: number;
};
async function listTickets(
accessToken: string,
opts?: { page?: number; limit?: number; status?: TicketStatus },
): Promise<TicketListResponse> {
const url = new URL('https://api.bio.re/api/v1/tickets');
if (opts?.page) url.searchParams.set('page', String(opts.page));
if (opts?.limit) url.searchParams.set('limit', String(opts.limit));
if (opts?.status) url.searchParams.set('status', opts.status);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'List failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery, keepPreviousData } from '@tanstack/react-query';
export const supportKeys = {
list: (opts: { page: number; status?: TicketStatus }) =>
['support', 'tickets', opts] as const,
detail: (ticketId: string) => ['support', 'ticket', ticketId] as const,
};
export function useMyTickets(page = 1, status?: TicketStatus) {
return useQuery({
queryKey: supportKeys.list({ page, status }),
queryFn: async () => {
const url = new URL('/api/v1/tickets', location.origin);
url.searchParams.set('page', String(page));
if (status) url.searchParams.set('status', status);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'List failed'), {
code: json?.error?.code,
});
}
return json.data as TicketListResponse;
},
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}Try it
Authorization
bearer In: header
Query Parameters
Page number (1-based)
Items per page (max 100)
Filter by ticket status
"OPEN" | "ASSIGNED" | "IN_PROGRESS" | "WAITING_USER" | "WAITING_INTERNAL" | "RESOLVED" | "CLOSED"Response Body
application/json
curl -X GET "https://loading/api/v1/tickets"{
"success": true,
"data": {
"items": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
"categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
"subject": "Payout delayed by 3 days",
"status": "OPEN",
"priority": "LOW",
"assignedTo": "7869c1a9-a680-4086-b6f5-9e78a651f6f2",
"resolvedAt": "2019-08-24T14:15:22Z",
"closedAt": "2019-08-24T14:15:22Z",
"createdAt": "2019-08-24T14:15:22Z",
"updatedAt": "2019-08-24T14:15:22Z",
"category": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "Billing & Payments",
"description": "string",
"priority": "LOW",
"active": true,
"sortOrder": 1,
"createdAt": "2019-08-24T14:15:22Z",
"updatedAt": "2019-08-24T14:15:22Z"
}
}
],
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/support/support.controller.ts | 20–46 (class), 27–46 (list) |
| Service | apps/api-core/src/modules/support/support.service.ts | 216–230 (listTickets — owner filter, ordering, pagination) |
| DTO (response) | apps/api-core/src/modules/support/dto/support-response.dto.ts | 41–77 (TicketListItemDto), 9–33 (TicketCategoryDto) |
| Throttle behavior | apps/api-core/src/common/guards/throttle.guard.ts | 43–48 (no-decorator → return true) |
| Global JWT guard | apps/api-core/src/app.module.ts | 62 (APP_GUARD: JwtAuthGuard) |
| Prisma model | packages/prisma/prisma/schema.prisma | Ticket lines 1245–1266, TicketStatus enum 809–817, Priority enum 819–824 |
Apply Coupon
Authenticated. Validates a coupon code against expiry, applicability, min purchase, total / per-user usage caps under a SELECT FOR UPDATE row lock. Returns the calculated discount.
Create Support Ticket
Open a new ticket as the current user. Returns just the new ticketId. Throttled to 5 per minute per IP. Optional category drives default priority. Sends a ticket_created notification fire-and-forget.