Reopen Ticket
Move a RESOLVED or CLOSED ticket back to OPEN. Clears resolvedAt + closedAt. Owner-only. Fails if the ticket is in any other status.
POST /api/v1/tickets/:ticketId/reopen — 🔑 Bearer
Transitions a ticket from RESOLVED or CLOSED back to OPEN. Both resolvedAt and closedAt are nulled in the same update. Use this when the user's underlying issue wasn't actually solved and they need an agent to look again.
Only RESOLVED and CLOSED can be reopened. The transition map says RESOLVED → CLOSED, OPEN and CLOSED → OPEN. Trying to reopen an already-active ticket (OPEN, ASSIGNED, IN_PROGRESS, WAITING_USER, WAITING_INTERNAL) returns 400 support.ticket.invalid_transition with the current status in the error payload. The transition validator runs before the ownership check.
Error precedence: not-found > invalid-transition > not-owner. The validator runs findUnique first (404 if missing), then checks the transition map (400 if not in RESOLVED/CLOSED), and only then does the ownership check (403). So if you try to reopen someone else's already-OPEN ticket, you get 400, not 403 — the existence + state of the ticket are revealed before authorization. Worth knowing if you build any abuse rules around this.
No notification fires from this endpoint. Unlike create and reply, the reopen path doesn't emit any ticket_* event — the admin/agent will see the ticket back at the top of the OPEN queue on their next refresh, but no push/email is sent.
Request
Path parameters
| Param | Type | Required | Validation | Notes |
|---|---|---|---|---|
ticketId | string (UUID) | ✓ | ParseUUIDPipe | Your ticket — must currently be in RESOLVED or CLOSED. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. |
No body.
Response
200 OK — SuccessOnlyResponseDto
{ "success": true }After this returns, fetching GET /tickets/:ticketId shows:
status: 'OPEN'resolvedAt: nullclosedAt: null- existing
messages[]unchanged (no new system message is appended)
The ticket is now back in the agent queue with no assignment carry-over (the previous assignedTo value is left as-is — only status, resolvedAt, closedAt are touched).
Errors
| HTTP | Code / i18nKey | Reason |
|---|---|---|
400 | (ParseUUIDPipe) | :ticketId is not a UUID. |
400 | support.ticket.invalid_transition | Ticket is in a status that doesn't allow OPEN (anything except RESOLVED / CLOSED). Payload: { currentStatus, targetStatus: 'OPEN' }. |
401 | (guard) | Missing / invalid bearer token. |
403 | support.ticket.not_owner | The ticket exists, is in RESOLVED/CLOSED, but belongs to another user. (See note above on error precedence.) |
404 | support.ticket.not_found | Ticket does not exist. |
Side effects
- Decode bearer;
ParseUUIDPipevalidates:ticketId. - Transition validator first:
prisma.ticket.findUnique({ where: { id } }). Missing → throw404. Then checkstatusis in['RESOLVED', 'CLOSED']; if not → throw400 invalid_transition. - Owner check second:
if (ticket.userId !== userId) throw 403 not_owner. prisma.ticket.update({ where: { id }, data: { status: 'OPEN', resolvedAt: null, closedAt: null } })— no transaction (single row write).- No notification, no log mutation, no message append. The audit trail of "user reopened" lives only in the status/timestamp diff.
- Return
{ success: true }.
Code samples
curl -X POST https://api.bio.re/api/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890/reopen \
-H "Authorization: Bearer $ACCESS_TOKEN"async function reopenTicket(
accessToken: string,
ticketId: string,
): Promise<void> {
const res = await fetch(
`https://api.bio.re/api/v1/tickets/${ticketId}/reopen`,
{
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
},
);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Reopen failed'), {
code: json?.error?.code,
// For invalid_transition, the API includes currentStatus in the payload:
currentStatus: json?.error?.payload?.currentStatus,
});
}
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useReopenTicket(ticketId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch(`/api/v1/tickets/${ticketId}/reopen`, {
method: 'POST',
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Reopen failed'), {
code: json?.error?.code,
});
}
},
onSuccess: () => {
// status flipped to OPEN, resolvedAt/closedAt cleared
qc.invalidateQueries({ queryKey: ['support', 'ticket', ticketId] });
qc.invalidateQueries({ queryKey: ['support', 'tickets'] });
},
});
}Try it
Authorization
bearer In: header
Path Parameters
Response Body
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/tickets/string/reopen"{
"success": true
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/support/support.controller.ts | 80–90 (reopen) |
| Service | apps/api-core/src/modules/support/support.service.ts | 130–134 (reopenTicket — transition validator + owner check + null timestamps) |
| Transition validator | apps/api-core/src/modules/support/support.service.ts | 36–48 (validateTicketTransition), 12–20 (VALID_TICKET_TRANSITIONS — only RESOLVED → OPEN and CLOSED → OPEN allowed) |
| Prisma model | packages/prisma/prisma/schema.prisma | Ticket lines 1245–1266, TicketStatus enum 809–817 |
Reply to Ticket
Add a user reply to your ticket. Auto-transitions WAITING_USER → IN_PROGRESS. Rejects when the ticket is CLOSED. Sends a ticket_update notification to the assigned agent (if any).
List Theme Presets
Public list of PUBLISHED theme presets for the creator bio editor / client-web theming. Sanitized output — only id/name/slug + light/dark/typography tokens.