Skip to main content

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:

TierGuardWho
UserJwtAuthGuardAny authenticated user
Support AgentJwtAuthGuard + SupportAgentGuardUsers with the support role (or super admins)
Super AdminJwtAuthGuard + SuperAdminGuardSuper admins only
Public (authenticated)JwtAuthGuardAny 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
}
}
}
info

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:

FieldTypeDefaultDescription
statusTicketStatus--Filter by status
priorityTicketPriority--Filter by priority
categoryTicketCategory--Filter by category
assignedToIdID--Filter by assigned agent
searchString--Case-insensitive subject search
sortByTicketSortByLAST_ACTIVITYSort field
sortOrderSupportSortOrderDESCSort direction
pageInt1Page number
limitInt12Items 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:

FieldTypeDefaultDescription
ticketIdID--Filter by ticket
actorIdID--Filter by actor (user who performed the action)
actionString--Filter by audit action
dateFromDateTime--Start of date range
dateToDateTime--End of date range
pageInt1Page number
limitInt30Items 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_CUSTOMERIN_PROGRESS (customer responded)
  • RESOLVEDOPEN (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.

Authorization note

sendTicketMessage is restricted to the ticket's creator or current assigneeisSuperAdmin 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.lastActivityAt is bumped to now() in the same transaction
  • A TicketAuditLog row is created with action: ATTACHMENT_REMOVED and metadata: { attachmentId, messageId, filename, messageDeleted }
  • supportTicketEvent (type: NEW_MESSAGE) is published to all support agents
  • myTicketUpdated (type: NEW_MESSAGE) is published to the ticket creator's personal channel

Errors:

ConditionCodeMessage
Attachment id not foundNOT_FOUND (404)Attachment not found
Caller not creator / agent / adminFORBIDDEN (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 OPENIN_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.lastActivityAt is bumped to now().
  • A TicketAuditLog row is written with action: TESTER_CHANGED, previousValue: <oldTesterId|null>, newValue: <newTesterId|null>, and metadata: { previousTesterId, newTesterId }.
  • Emits the support.ticket.tester.changed domain event with a TicketTesterChangedEvent payload (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
}
}
warning
createdById has no public mutation

SupportTicket.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 OPENIN_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
}
}
tip

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

FieldTypeDescription
ticketSupportTicketOutput!Updated ticket state
newMessageTicketMessageOutputNew message (only on NEW_MESSAGE events)
typeTicketEventType!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

FieldTypeDescription
ticketSupportTicketOutput!Affected ticket
typeTicketEventType!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.

PropertyValue
Schedule0 * * * * (every hour at minute 0)
ConditionTickets in RESOLVED status where resolvedAt is older than 7 days
ActionSets status to CLOSED, adds a system message, creates audit log entry
ActorFirst super admin user (system actor for audit trail)

When tickets are auto-closed, the system:

  1. Updates status to CLOSED with closedAt timestamp
  2. Creates an audit log entry with metadata { reason: "auto_close_7_days" }
  3. Adds a visible system message: "This ticket was automatically closed after 7 days of inactivity in resolved status."
  4. Emits the support.ticket.auto.closed domain event
  5. Publishes a supportTicketEvent subscription event

Domain Events

The support module emits the following events via @nestjs/event-emitter:

EventTrigger
support.ticket.createdNew ticket created
support.ticket.status.changedStatus updated
support.ticket.assignedAgent assigned
support.ticket.tester.changedTester set, changed, or cleared via assignTester
support.ticket.message.sentUser sends message
support.ticket.support.replyAgent replies
support.ticket.internal.noteInternal note added
support.ticket.priority.urgentPriority set to URGENT
support.ticket.reopenedTicket reopened
support.ticket.resolvedTicket resolved
support.ticket.closedTicket closed
support.ticket.auto.closedAuto-close batch completed

Error Handling

All errors follow the standard GraphQL error format with extensions:

ErrorCodeTrigger
Ticket not foundNOT_FOUND (404)Invalid ticketId
Not your ticketFORBIDDEN (403)User tries to access/modify another user's ticket
Agent access requiredFORBIDDEN (403)Non-agent accessing support portal operations
Super admin requiredFORBIDDEN (403)Non-admin accessing admin operations
Cannot message closed ticketBAD_REQUEST (400)Sending a message on a CLOSED ticket
Ticket already in statusBAD_REQUEST (400)Setting a ticket to its current status
Only resolved/closed can reopenBAD_REQUEST (400)Reopening an OPEN or IN_PROGRESS ticket
User not a support agentBAD_REQUEST (400)Assigning a ticket to a non-agent user
No content or attachmentsBAD_REQUEST (400)Message must have content or at least one attachment
Invalid attachment typeBAD_REQUEST (400)File content type not in allowed list
Attachment too largeBAD_REQUEST (400)File exceeds 10 MB limit
Too many attachmentsBAD_REQUEST (400)More than 5 attachments per message
Invalid objectPathBAD_REQUEST (400)objectPath must start with uploads/
User already has support roleCONFLICT (409)Assigning support role to an existing agent
FAQ not foundNOT_FOUND (404)Invalid FAQ entry ID

Database Models

The support system uses 6 Prisma models:

ModelPurpose
SupportTicketCore ticket entity with status, priority, category, assignment
TicketMessageMessages on tickets (user, support, system) with internal note flag
TicketAttachmentFile attachments on messages (S3 storage)
TicketAuditLogImmutable audit trail of all ticket actions
FaqEntryFAQ articles with status lifecycle (DRAFT → PUBLISHED → ARCHIVED)
FaqVoteDe-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

ConstantValueDescription
Ticket number prefixSUP-All ticket numbers start with this prefix
Ticket number padding5 digitse.g., SUP-00001
Default page1First page
Tickets per page12Default ticket list limit
Audit logs per page30Default audit log limit
FAQs per page12Default FAQ list limit
Auto-close days7Days in RESOLVED before auto-close
Presigned URL TTL900 seconds15-minute download URL expiry
Max attachments per message5Attachment count limit
Max attachment size10 MBPer-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