Support Ticket API
GraphQL API for the support ticket system. Covers user-facing ticket operations, the support agent portal, FAQ management, admin oversight, and real-time subscriptions. All operations require JWT authentication via the Authorization: Bearer <token> header.
Endpoint: https://api.aiqlick.com/graphql (production) | https://api-dev.aiqlick.com/graphql (development)
Overview
The support system is organized into four access tiers:
| Tier | Guard | Who |
|---|---|---|
| User | JwtAuthGuard | Any authenticated user |
| Support Agent | JwtAuthGuard + SupportAgentGuard | Users with the support role (or super admins) |
| Super Admin | JwtAuthGuard + SuperAdminGuard | Super admins only |
| Public (authenticated) | JwtAuthGuard | Any authenticated user (FAQ browsing) |
GraphQL Types
Enums
enum TicketCategory {
GENERAL
BILLING
TECHNICAL
ACCOUNT
FEATURE_REQUEST
BUG_REPORT
}
enum TicketPriority {
LOW
MEDIUM
HIGH
URGENT
}
enum TicketStatus {
OPEN
IN_PROGRESS
WAITING_ON_CUSTOMER
RESOLVED
CLOSED
}
enum TicketMessageSenderRole {
USER
SUPPORT
SYSTEM
}
enum TicketAuditAction {
CREATED
STATUS_CHANGED
PRIORITY_CHANGED
ASSIGNED
UNASSIGNED
REASSIGNED
TESTER_CHANGED
MESSAGE_SENT
INTERNAL_NOTE_ADDED
ATTACHMENT_ADDED
ATTACHMENT_REMOVED
CATEGORY_CHANGED
DESCRIPTION_CHANGED
MESSAGE_EDITED
MESSAGE_DELETED
RESOLVED
CLOSED
REOPENED
DELETED
ARCHIVED
UNARCHIVED
}
enum FaqStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum TicketSortBy {
CREATED_AT
LAST_ACTIVITY
PRIORITY
}
enum SupportSortOrder {
ASC
DESC
}
enum TicketEventType {
NEW_TICKET
NEW_MESSAGE
STATUS_CHANGED
ASSIGNED
TESTER_CHANGED
PRIORITY_CHANGED
DESCRIPTION_CHANGED
}
Object Types
type TicketUser {
id: ID!
firstName: String!
lastName: String!
email: String
profileImageUrl: String
}
type SupportTicketOutput {
id: ID!
ticketNumber: String! # Sequential: SUP-00001, SUP-00002, ...
subject: String!
description: String!
category: TicketCategory!
priority: TicketPriority!
status: TicketStatus!
createdById: ID!
createdBy: TicketUser
assignedToId: ID
assignedTo: TicketUser
testerId: ID # Optional — user responsible for verifying the fix
tester: TicketUser
companyId: ID
lastActivityAt: DateTime!
firstResponseAt: DateTime # Set on first support reply
resolvedAt: DateTime
closedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
type TicketMessageOutput {
id: ID!
ticketId: ID!
senderId: ID!
sender: TicketUser!
senderRole: TicketMessageSenderRole!
content: String!
isInternalNote: Boolean! # True = visible only to support agents
attachments: [TicketAttachmentOutput!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type TicketAttachmentOutput {
id: ID!
messageId: ID!
filename: String!
contentType: String!
sizeBytes: Int!
objectPath: String!
downloadUrl: String # Presigned S3 URL (15-min expiry), resolved via field resolver
createdAt: DateTime!
}
type TicketAuditLogOutput {
id: ID!
ticketId: ID!
actorId: ID!
actor: TicketUser!
action: TicketAuditAction!
previousValue: String
newValue: String
metadata: JSON
createdAt: DateTime!
}
type RemoveTicketAttachmentResult {
ticketId: ID!
attachmentId: ID!
messageId: ID # Null when the parent message was also deleted
messageDeleted: Boolean!
}
type FaqEntryOutput {
id: ID!
question: String!
answer: String!
category: TicketCategory!
tags: [String!]!
sourceTicketId: ID
status: FaqStatus!
createdById: ID!
publishedAt: DateTime
viewCount: Int!
helpfulCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
Paginated Types
type PaginatedTickets {
items: [SupportTicketOutput!]!
total: Int!
page: Int!
limit: Int!
}
type PaginatedAuditLogs {
items: [TicketAuditLogOutput!]!
total: Int!
page: Int!
limit: Int!
}
type PaginatedFaqs {
items: [FaqEntryOutput!]!
total: Int!
page: Int!
limit: Int!
}
Response Types
type BulkActionResponse {
success: Boolean!
message: String!
updatedCount: Int!
}
Composite Types
type TicketDetailOutput {
ticket: SupportTicketOutput!
messages: [TicketMessageOutput!]!
auditLog: [TicketAuditLogOutput!] # Null for user-facing view; populated for support agents
}
type SupportDashboardStats {
totalOpen: Int!
totalInProgress: Int!
totalWaitingOnCustomer: Int!
totalResolved: Int!
totalClosed: Int!
avgFirstResponseTime: Float! # Minutes
avgResolutionTime: Float! # Minutes
ticketsByCategory: [CategoryCount!]!
ticketsByPriority: [PriorityCount!]!
}
type AdminSupportOverview {
# Extends SupportDashboardStats
totalOpen: Int!
totalInProgress: Int!
totalWaitingOnCustomer: Int!
totalResolved: Int!
totalClosed: Int!
avgFirstResponseTime: Float!
avgResolutionTime: Float!
ticketsByCategory: [CategoryCount!]!
ticketsByPriority: [PriorityCount!]!
agentPerformance: [AgentPerformance!]!
}
type AgentPerformance {
agentId: ID!
agentName: String!
assignedCount: Int!
resolvedCount: Int!
avgResponseTime: Float!
avgResolutionTime: Float!
}
type SupportAgentOutput {
id: ID!
firstName: String!
lastName: String!
email: String!
profileImageUrl: String
assignedTicketCount: Int!
resolvedTicketCount: Int!
}
Input Types
input CreateSupportTicketInput {
subject: String! # Max 500 chars
description: String!
category: TicketCategory!
priority: TicketPriority # Default: MEDIUM
testerId: ID # Optional — pre-assign a tester at creation time
}
input TicketAttachmentInput {
filename: String! # Max 500 chars
contentType: String! # Must be an allowed MIME type (max 200 chars)
sizeBytes: Int! # 1 – 10,485,760 (10 MB)
objectPath: String! # Must start with "uploads/" (max 1000 chars)
}
input SendTicketMessageInput {
ticketId: ID!
content: String # Optional (max 10,000 chars) — required if no attachments
attachments: [TicketAttachmentInput!] # Optional (max 5) — required if no content
}
input SendSupportReplyInput {
ticketId: ID!
content: String # Optional (max 10,000 chars) — required if no attachments
attachments: [TicketAttachmentInput!] # Optional (max 5) — required if no content
}
input AddInternalNoteInput {
ticketId: ID!
content: String # Optional (max 10,000 chars) — required if no attachments
attachments: [TicketAttachmentInput!] # Optional (max 5) — required if no content
}
input MyTicketsInput {
status: TicketStatus
page: Int # Default: 1
limit: Int # Default: 12
}
input SupportTicketsInput {
status: TicketStatus
priority: TicketPriority
category: TicketCategory
assignedToId: ID
search: String # Case-insensitive subject search
sortBy: TicketSortBy # Default: LAST_ACTIVITY
sortOrder: SupportSortOrder # Default: DESC
page: Int # Default: 1
limit: Int # Default: 12
}
input AdminSupportAuditLogInput {
ticketId: ID
actorId: ID
action: String
dateFrom: DateTime
dateTo: DateTime
page: Int # Default: 1
limit: Int # Default: 30
}
FAQ Input Types
input CreateFaqInput {
question: String!
answer: String!
category: TicketCategory!
tags: [String!]
sourceTicketId: ID # Link to originating ticket
status: FaqStatus # Default: DRAFT
}
input UpdateFaqInput {
question: String
answer: String
category: TicketCategory
tags: [String!]
status: FaqStatus
}
input PublishedFaqsInput {
category: TicketCategory
search: String # Searches question, answer, and tags
page: Int # Default: 1
limit: Int # Default: 12
}
input SupportFaqsInput {
status: FaqStatus
category: TicketCategory
search: String
page: Int
limit: Int
}
Response Types
type SupportMutationResponse {
success: Boolean!
message: String
}
type FaqHelpfulResponse {
id: ID!
helpfulCount: Int!
}
type FaqSuggestionResponse {
question: String!
answer: String!
tags: [String!]!
}
type UnreadTicketCount {
count: Int!
}
Queries
User Queries
myTickets
Paginated list of the current user's support tickets.
query MyTickets($input: MyTicketsInput) {
myTickets(input: $input) {
items {
id
ticketNumber
subject
category
priority
status
assignedTo { firstName lastName }
lastActivityAt
createdAt
}
total
page
limit
}
}
myTicketDetail
Full ticket detail with messages (excludes internal notes for regular users).
query MyTicketDetail($ticketId: ID!) {
myTicketDetail(ticketId: $ticketId) {
ticket {
id
ticketNumber
subject
description
category
priority
status
createdBy { firstName lastName email }
assignedTo { firstName lastName }
firstResponseAt
resolvedAt
closedAt
createdAt
}
messages {
id
sender { firstName lastName profileImageUrl }
senderRole
content
attachments { id filename contentType sizeBytes downloadUrl }
createdAt
}
}
}
The auditLog field is always null for user-facing queries. Only support agents see the audit trail via supportTicketDetail.
myUnreadTicketCount
Returns the count of the user's non-closed tickets that have at least one support reply.
query {
myUnreadTicketCount {
count
}
}
Support Agent Queries
All support agent queries require the SupportAgentGuard -- the user must have the support role or be a super admin.
supportTickets
Paginated, filtered, sortable list of all tickets in the system.
query SupportTickets($input: SupportTicketsInput!) {
supportTickets(input: $input) {
items {
id
ticketNumber
subject
category
priority
status
createdBy { firstName lastName email }
assignedTo { firstName lastName }
lastActivityAt
createdAt
}
total
page
limit
}
}
Input fields:
| Field | Type | Default | Description |
|---|---|---|---|
status | TicketStatus | -- | Filter by status |
priority | TicketPriority | -- | Filter by priority |
category | TicketCategory | -- | Filter by category |
assignedToId | ID | -- | Filter by assigned agent |
search | String | -- | Case-insensitive subject search |
sortBy | TicketSortBy | LAST_ACTIVITY | Sort field |
sortOrder | SupportSortOrder | DESC | Sort direction |
page | Int | 1 | Page number |
limit | Int | 12 | Items per page |
supportTicketDetail
Full ticket detail including internal notes and audit log (agent-only view).
query SupportTicketDetail($ticketId: ID!) {
supportTicketDetail(ticketId: $ticketId) {
ticket { ... }
messages {
id
sender { firstName lastName }
senderRole
content
isInternalNote
attachments { id filename downloadUrl }
createdAt
}
auditLog {
id
actor { firstName lastName }
action
previousValue
newValue
metadata
createdAt
}
}
}
supportDashboardStats
Aggregate statistics for the support dashboard.
query {
supportDashboardStats {
totalOpen
totalInProgress
totalWaitingOnCustomer
totalResolved
totalClosed
avgFirstResponseTime
avgResolutionTime
ticketsByCategory { category count }
ticketsByPriority { priority count }
}
}
listSupportAgents
Lists all users with the support agent role. Returns lightweight user data suitable for dropdowns (e.g. "Assigned To" selector). Unlike adminListSupportAgents, this does not include performance metrics.
Guard: SupportAgentGuard (support agents + super admins)
query {
listSupportAgents {
id
firstName
lastName
email
profileImageUrl
}
}
Returns: [TicketUser!]!
availableSupportAgents
Lists all support agents. Unlike listSupportAgents, this is accessible to any authenticated user (not just support agents). Use this when building ticket creation forms or agent picker dropdowns for regular users.
Guard: JwtAuthGuard (any authenticated user)
query {
availableSupportAgents {
id
firstName
lastName
email
profileImageUrl
}
}
Returns: [TicketUser!]!
Frontend usage:
// Hook (recommended)
import { useAvailableSupportAgents } from "@hooks/useSupport"
const { agents, loading } = useAvailableSupportAgents()
// Or direct query
import { AVAILABLE_SUPPORT_AGENTS } from "@graphql/support/queries"
const { data } = useQuery(AVAILABLE_SUPPORT_AGENTS)
const agents = data?.availableSupportAgents ?? []
FAQ Queries
publishedFaqs
Public-facing FAQ list (only PUBLISHED entries). Available to any authenticated user.
query PublishedFaqs($input: PublishedFaqsInput) {
publishedFaqs(input: $input) {
items {
id
question
answer
category
tags
helpfulCount
viewCount
}
total
page
limit
}
}
supportFaqs
All FAQ entries across all statuses. Support agent access required.
query SupportFaqs($input: SupportFaqsInput) {
supportFaqs(input: $input) {
items {
id
question
answer
category
tags
status
sourceTicketId
publishedAt
helpfulCount
viewCount
createdAt
updatedAt
}
total
page
limit
}
}
Admin Queries
All admin queries require super admin access.
adminSupportOverview
Dashboard stats plus per-agent performance metrics.
query {
adminSupportOverview {
totalOpen
totalInProgress
totalWaitingOnCustomer
totalResolved
totalClosed
avgFirstResponseTime
avgResolutionTime
ticketsByCategory { category count }
ticketsByPriority { priority count }
agentPerformance {
agentId
agentName
assignedCount
resolvedCount
avgResponseTime
avgResolutionTime
}
}
}
adminListSupportAgents
Lists all users with the support agent role, including performance metrics (ticket counts). Super admin only — support agents should use listSupportAgents instead.
query {
adminListSupportAgents {
id
firstName
lastName
email
profileImageUrl
assignedTicketCount
resolvedTicketCount
}
}
adminSupportAuditLog
Paginated, filtered audit log across all tickets.
query AdminAuditLog($input: AdminSupportAuditLogInput) {
adminSupportAuditLog(input: $input) {
items {
id
ticketId
actor { firstName lastName }
action
previousValue
newValue
metadata
createdAt
}
total
page
limit
}
}
Input fields:
| Field | Type | Default | Description |
|---|---|---|---|
ticketId | ID | -- | Filter by ticket |
actorId | ID | -- | Filter by actor (user who performed the action) |
action | String | -- | Filter by audit action |
dateFrom | DateTime | -- | Start of date range |
dateTo | DateTime | -- | End of date range |
page | Int | 1 | Page number |
limit | Int | 30 | Items per page |
Mutations
User Mutations
createSupportTicket
Creates a new support ticket. Automatically sets status to OPEN and assigns the user's selectedCompanyId as company context.
mutation CreateTicket($input: CreateSupportTicketInput!) {
createSupportTicket(input: $input) {
id
ticketNumber
subject
status
priority
category
createdAt
}
}
Publishes: supportTicketEvent (type: NEW_TICKET) to the support agent subscription.
sendTicketMessage
Send a message on the user's own ticket. Supports text, file attachments, or both. At least one of content or attachments is required.
mutation SendMessage($input: SendTicketMessageInput!) {
sendTicketMessage(input: $input) {
id
content
senderRole
attachments { id filename contentType sizeBytes downloadUrl }
createdAt
}
}
Auto-transitions:
WAITING_ON_CUSTOMER→IN_PROGRESS(customer responded)RESOLVED→OPEN(customer reopened by replying)
Validation: Cannot send messages on CLOSED tickets -- reopen first. Attachments are validated for file type, size (10 MB max), count (5 max), and S3 path format.
sendTicketMessage is restricted to the ticket's creator or current assignee — isSuperAdmin does not bypass this check. Attempting to reply as a third-party agent raises You can only send messages on your own tickets (HTTP 403). To post from a different account, first call assignTicket to re-target the ticket to that agent — but note assignTicket itself requires the target user to have isSupportAgent = true (grant via adminAssignSupportRole first).
closeMyTicket
Close the user's own ticket.
mutation CloseMyTicket($ticketId: ID!) {
closeMyTicket(ticketId: $ticketId) {
id
status
closedAt
}
}
reopenMyTicket
Reopen a RESOLVED or CLOSED ticket.
mutation ReopenMyTicket($ticketId: ID!) {
reopenMyTicket(ticketId: $ticketId) {
id
status
}
}
updateMyTicketDescription
Edit the description of the user's own ticket. Ownership is verified server-side.
mutation UpdateMyDescription($ticketId: ID!, $description: String!) {
updateMyTicketDescription(ticketId: $ticketId, description: $description) {
id
description
}
}
removeTicketAttachment
Remove a previously-uploaded attachment from a ticket message. Despite living on the user resolver (JwtAuthGuard only), the service performs the role check internally so the same mutation is used by ticket owners, support agents, and super admins.
mutation RemoveAttachment($attachmentId: ID!) {
removeTicketAttachment(attachmentId: $attachmentId) {
ticketId
attachmentId
messageId # null if the parent message was also deleted
messageDeleted
}
}
Authorization (any of):
- Ticket creator (
SupportTicket.createdById === currentUser.id) - Super admin (
User.isSuperAdmin) - Support agent (
SupportRoleService.isSupportAgent(currentUser.id))
Otherwise: ForbiddenException (403).
Cascade rule: if removing the attachment would leave the parent TicketMessage with no other attachments and empty trimmed content, the parent message is deleted in the same transaction (DB cascade removes the attachment row). The result returns messageDeleted: true and messageId: null in that case.
S3 cleanup: deleted DB-first inside the transaction, then StorageService.deleteFile(objectPath) is called best-effort. A failed S3 delete is logged but does not roll back the DB change — an orphan blob is recoverable, while a missing blob behind a live row is a permanently broken download link.
Side effects:
SupportTicket.lastActivityAtis bumped tonow()in the same transaction- A
TicketAuditLogrow is created withaction: ATTACHMENT_REMOVEDandmetadata: { attachmentId, messageId, filename, messageDeleted } supportTicketEvent(type:NEW_MESSAGE) is published to all support agentsmyTicketUpdated(type:NEW_MESSAGE) is published to the ticket creator's personal channel
Errors:
| Condition | Code | Message |
|---|---|---|
| Attachment id not found | NOT_FOUND (404) | Attachment not found |
| Caller not creator / agent / admin | FORBIDDEN (403) | You do not have permission to remove this attachment |
There is no ticket-status restriction — removal is allowed even on CLOSED tickets.
Support Agent Mutations
All support agent mutations require the SupportAgentGuard.
assignTicket
Assign a ticket to a support agent. Auto-transitions OPEN → IN_PROGRESS.
mutation AssignTicket($ticketId: ID!, $agentId: ID!) {
assignTicket(ticketId: $ticketId, agentId: $agentId) {
id
status
assignedTo { firstName lastName }
}
}
Validation: Target user must have the support role.
assignTester
Set, change, or clear the tester on a ticket — the user responsible for verifying that a reported bug is actually fixed (or that a delivered feature behaves correctly). Pass testerId: null to clear.
mutation AssignTester($ticketId: ID!, $testerId: ID) {
assignTester(ticketId: $ticketId, testerId: $testerId) {
id
testerId
tester { firstName lastName }
}
}
Guard: JwtAuthGuard + SupportAgentGuard — only support agents can change a ticket's tester.
Validation: Unlike assignTicket, the target user does not need the support role — testers can be anyone (the original reporter, a developer, a QA engineer). The mutation only verifies the user exists.
Side effects:
SupportTicket.lastActivityAtis bumped tonow().- A
TicketAuditLogrow is written withaction: TESTER_CHANGED,previousValue: <oldTesterId|null>,newValue: <newTesterId|null>, andmetadata: { previousTesterId, newTesterId }. - Emits the
support.ticket.tester.changeddomain event with aTicketTesterChangedEventpayload (ticket id/number/subject/priority/category, old + new tester id/email/firstName, ticket creator id, actor id and name). - Publishes
supportTicketEvent(type:TESTER_CHANGED) to the agent subscription channel.
No-op behavior: if testerId already matches the current value, the mutation returns the unchanged ticket without writing an audit log or emitting events.
updateTicketStatus
Change a ticket's status.
mutation UpdateStatus($ticketId: ID!, $status: TicketStatus!) {
updateTicketStatus(ticketId: $ticketId, status: $status) {
id
status
resolvedAt
closedAt
}
}
Publishes: supportTicketEvent (type: STATUS_CHANGED) and myTicketUpdated to the ticket creator.
updateTicketPriority
Change a ticket's priority level.
mutation UpdatePriority($ticketId: ID!, $priority: TicketPriority!) {
updateTicketPriority(ticketId: $ticketId, priority: $priority) {
id
priority
}
}
updateTicketCategory
Recategorize a ticket.
mutation UpdateCategory($ticketId: ID!, $category: TicketCategory!) {
updateTicketCategory(ticketId: $ticketId, category: $category) {
id
category
}
}
sendSupportReply
Send a reply to the user (visible to the ticket creator). Supports text, file attachments, or both. Updates lastActivityAt and sets firstResponseAt if this is the first agent reply. Does not change the ticket status — agents must call updateTicketStatus explicitly when they genuinely need to move the ticket to WAITING_ON_CUSTOMER or elsewhere. (Prior to 2026-04-23 this mutation auto-transitioned to WAITING_ON_CUSTOMER; that behaviour was removed because the portal hid that status from the agent queue and replied tickets vanished from the replier's own dashboard.)
mutation SendReply($input: SendSupportReplyInput!) {
sendSupportReply(input: $input) {
id
content
senderRole
attachments { id filename contentType sizeBytes downloadUrl }
createdAt
}
}
Publishes: myTicketUpdated to the ticket creator and supportTicketEvent to all agents.
addInternalNote
Add an internal note visible only to support agents. Supports text, file attachments, or both. Does not change ticket status.
mutation AddNote($input: AddInternalNoteInput!) {
addInternalNote(input: $input) {
id
content
isInternalNote
attachments { id filename contentType sizeBytes downloadUrl }
createdAt
}
}
updateTicketDescription
Edit a ticket's description. Support agents can edit any ticket's description. Creates a DESCRIPTION_CHANGED audit log entry.
mutation UpdateDescription($ticketId: ID!, $description: String!) {
updateTicketDescription(ticketId: $ticketId, description: $description) {
id
description
}
}
createdById has no public mutationSupportTicket.createdById is immutable through the GraphQL API — there is no updateTicketCreator or user-level mergeUsers mutation. Changing the reporter on an existing ticket (e.g. consolidating a duplicate support-agent account) requires a direct SQL UPDATE against the prod RDS. See the Support Agent Account Consolidation runbook for the transaction template and the SSM tunnel setup.
bulkAssignTickets
Assign multiple tickets to a single agent in one operation. Auto-transitions OPEN → IN_PROGRESS for each ticket. Failures on individual tickets are logged but do not block the rest.
mutation BulkAssign($ticketIds: [ID!]!, $agentId: ID!) {
bulkAssignTickets(ticketIds: $ticketIds, agentId: $agentId) {
success
message
updatedCount
}
}
Validation: Target agent must have the support role.
bulkUpdateTicketStatus
Change the status of multiple tickets at once. Handles timestamp logic (resolvedAt, closedAt) per ticket. Failures on individual tickets are logged but do not block the rest.
mutation BulkStatus($ticketIds: [ID!]!, $status: TicketStatus!) {
bulkUpdateTicketStatus(ticketIds: $ticketIds, status: $status) {
success
message
updatedCount
}
}
FAQ Mutations
createFaqEntry
Create a new FAQ entry. Support agent access required.
mutation CreateFaq($input: CreateFaqInput!) {
createFaqEntry(input: $input) {
id
question
answer
category
status
tags
}
}
updateFaqEntry
Update an existing FAQ entry. Support agent access required.
mutation UpdateFaq($id: ID!, $input: UpdateFaqInput!) {
updateFaqEntry(id: $id, input: $input) {
id
question
answer
status
publishedAt
}
}
Setting status: PUBLISHED automatically sets publishedAt if it has not been set previously.
deleteFaqEntry
Permanently delete a FAQ entry. Super admin only.
mutation DeleteFaq($id: ID!) {
deleteFaqEntry(id: $id) {
success
message
}
}
markFaqHelpful
Mark a FAQ entry as helpful (one vote per user, de-duplicated).
mutation MarkHelpful($id: ID!) {
markFaqHelpful(id: $id) {
id
helpfulCount
}
}
generateFaqSuggestion
Generate a FAQ suggestion from an existing ticket's subject and last support reply. Support agent access required.
mutation GenerateSuggestion($ticketId: ID!) {
generateFaqSuggestion(ticketId: $ticketId) {
question
answer
tags
}
}
Admin Mutations
All admin mutations require super admin access.
adminAssignSupportRole
Grant the support agent role to a user.
mutation AssignRole($userId: ID!) {
adminAssignSupportRole(userId: $userId) {
success
message
}
}
adminRevokeSupportRole
Revoke the support agent role from a user.
mutation RevokeRole($userId: ID!) {
adminRevokeSupportRole(userId: $userId) {
success
message
}
}
Subscriptions
Two WebSocket subscriptions for real-time ticket updates. Both use Redis pub/sub via NotificationPubSubService.
myTicketUpdated
Fires when a ticket created by the current user receives an update (new message, status change, assignment). Filtered by userId and optionally by ticketId.
subscription MyTicketUpdated($ticketId: ID) {
myTicketUpdated(ticketId: $ticketId) {
ticket {
id
ticketNumber
status
priority
assignedTo { firstName lastName }
}
newMessage {
id
content
senderRole
createdAt
}
type
}
}
Payload type: TicketUpdateEvent
| Field | Type | Description |
|---|---|---|
ticket | SupportTicketOutput! | Updated ticket state |
newMessage | TicketMessageOutput | New message (only on NEW_MESSAGE events) |
type | TicketEventType! | NEW_MESSAGE, STATUS_CHANGED, ASSIGNED, PRIORITY_CHANGED |
Filter logic: Only delivers events where ticket.createdById matches the authenticated user. If ticketId argument is provided, also filters to that specific ticket.
supportTicketEvent
Fires for all ticket events across the platform. Support agent access required (SupportAgentGuard).
subscription {
supportTicketEvent {
ticket {
id
ticketNumber
status
priority
category
createdBy { firstName lastName }
assignedTo { firstName lastName }
}
type
}
}
Payload type: SupportTicketEventPayload
| Field | Type | Description |
|---|---|---|
ticket | SupportTicketOutput! | Affected ticket |
type | TicketEventType! | NEW_TICKET, NEW_MESSAGE, STATUS_CHANGED, ASSIGNED, PRIORITY_CHANGED |
Field Resolvers
TicketAttachmentOutput.downloadUrl
Resolved via TicketAttachmentResolver using StorageService.getPresignedUrl(). Generates a presigned S3 download URL with a 15-minute TTL. Returns null if objectPath is missing.
User.isSupportAgent
Resolved via UserSupportAgentFieldResolver. Adds a computed isSupportAgent: Boolean field to the User type by checking the user's role assignments against the cached support role ID.
Auto-Close Cron Job
A cron job runs every hour to automatically close stale resolved tickets.
| Property | Value |
|---|---|
| Schedule | 0 * * * * (every hour at minute 0) |
| Condition | Tickets in RESOLVED status where resolvedAt is older than 7 days |
| Action | Sets status to CLOSED, adds a system message, creates audit log entry |
| Actor | First super admin user (system actor for audit trail) |
When tickets are auto-closed, the system:
- Updates status to
CLOSEDwithclosedAttimestamp - Creates an audit log entry with metadata
{ reason: "auto_close_7_days" } - Adds a visible system message: "This ticket was automatically closed after 7 days of inactivity in resolved status."
- Emits the
support.ticket.auto.closeddomain event - Publishes a
supportTicketEventsubscription event
Domain Events
The support module emits the following events via @nestjs/event-emitter:
| Event | Trigger |
|---|---|
support.ticket.created | New ticket created |
support.ticket.status.changed | Status updated |
support.ticket.assigned | Agent assigned |
support.ticket.tester.changed | Tester set, changed, or cleared via assignTester |
support.ticket.message.sent | User sends message |
support.ticket.support.reply | Agent replies |
support.ticket.internal.note | Internal note added |
support.ticket.priority.urgent | Priority set to URGENT |
support.ticket.reopened | Ticket reopened |
support.ticket.resolved | Ticket resolved |
support.ticket.closed | Ticket closed |
support.ticket.auto.closed | Auto-close batch completed |
Error Handling
All errors follow the standard GraphQL error format with extensions:
| Error | Code | Trigger |
|---|---|---|
| Ticket not found | NOT_FOUND (404) | Invalid ticketId |
| Not your ticket | FORBIDDEN (403) | User tries to access/modify another user's ticket |
| Agent access required | FORBIDDEN (403) | Non-agent accessing support portal operations |
| Super admin required | FORBIDDEN (403) | Non-admin accessing admin operations |
| Cannot message closed ticket | BAD_REQUEST (400) | Sending a message on a CLOSED ticket |
| Ticket already in status | BAD_REQUEST (400) | Setting a ticket to its current status |
| Only resolved/closed can reopen | BAD_REQUEST (400) | Reopening an OPEN or IN_PROGRESS ticket |
| User not a support agent | BAD_REQUEST (400) | Assigning a ticket to a non-agent user |
| No content or attachments | BAD_REQUEST (400) | Message must have content or at least one attachment |
| Invalid attachment type | BAD_REQUEST (400) | File content type not in allowed list |
| Attachment too large | BAD_REQUEST (400) | File exceeds 10 MB limit |
| Too many attachments | BAD_REQUEST (400) | More than 5 attachments per message |
| Invalid objectPath | BAD_REQUEST (400) | objectPath must start with uploads/ |
| User already has support role | CONFLICT (409) | Assigning support role to an existing agent |
| FAQ not found | NOT_FOUND (404) | Invalid FAQ entry ID |
Database Models
The support system uses 6 Prisma models:
| Model | Purpose |
|---|---|
SupportTicket | Core ticket entity with status, priority, category, assignment |
TicketMessage | Messages on tickets (user, support, system) with internal note flag |
TicketAttachment | File attachments on messages (S3 storage) |
TicketAuditLog | Immutable audit trail of all ticket actions |
FaqEntry | FAQ articles with status lifecycle (DRAFT → PUBLISHED → ARCHIVED) |
FaqVote | De-duplicated helpful votes on FAQ entries (@@unique([faqId, userId])) |
Support agent role assignment is managed through the existing UserRole table with a support role that is auto-created on module initialization via SupportRoleService.onModuleInit().
Constants
| Constant | Value | Description |
|---|---|---|
| Ticket number prefix | SUP- | All ticket numbers start with this prefix |
| Ticket number padding | 5 digits | e.g., SUP-00001 |
| Default page | 1 | First page |
| Tickets per page | 12 | Default ticket list limit |
| Audit logs per page | 30 | Default audit log limit |
| FAQs per page | 12 | Default FAQ list limit |
| Auto-close days | 7 | Days in RESOLVED before auto-close |
| Presigned URL TTL | 900 seconds | 15-minute download URL expiry |
| Max attachments per message | 5 | Attachment count limit |
| Max attachment size | 10 MB | Per-file size limit |
Allowed Attachment Types
Images: image/jpeg, image/png, image/gif, image/webp, image/svg+xml
Documents: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document
Spreadsheets: application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Presentations: application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation
Text: text/plain, text/csv
Archives: application/zip, application/x-zip-compressed