Skip to main content

Support/Help Desk System -- Backend Specification

Status: Frontend fully implemented. Backend implementation required. Frontend types: lib/types/support.ts Frontend GraphQL operations: graphql/operations/support/ (queries, mutations, subscriptions) Frontend fragments: graphql/operations/schema/support/fragments.ts


Table of Contents

  1. Overview
  2. Database Schema
  3. GraphQL Type Definitions (SDL)
  4. Resolver Specifications
  5. Support Role (RBAC Integration)
  6. Ticket Lifecycle
  7. Authorization Matrix
  8. Notification Integration
  9. API Design Notes
  10. Frontend Integration Notes

Overview

The Support/Help Desk system provides ticket-based support where users submit help requests, support agents manage cases through a dedicated portal, and super admins have full oversight via the admin panel. All actions are audited.

The system has three access tiers:

TierAudienceRoute
User-facingAll authenticated users/support, sidebar drawer
Support PortalSupport agents + super admins/support-portal
Admin PanelSuper admins only/admin/support

Additionally, a FAQ/Knowledge Base system allows support agents to document resolved tickets as searchable FAQ entries, reducing repeat tickets.


Database Schema

Prerequisites

The database is PostgreSQL. All IDs are UUIDs. Timestamps use timestamptz. The existing User, Role, UserCompanyRole, and Company tables are referenced via foreign keys.

Table: SupportTicket

CREATE TABLE "SupportTicket" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"ticketNumber" VARCHAR(20) NOT NULL UNIQUE,
"subject" VARCHAR(500) NOT NULL,
"description" TEXT NOT NULL, -- HTML from RichTextEditor
"category" VARCHAR(30) NOT NULL, -- enum: TicketCategory
"priority" VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- enum: TicketPriority
"status" VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- enum: TicketStatus
"createdById" UUID NOT NULL REFERENCES "User"("id"),
"assignedToId" UUID REFERENCES "User"("id"), -- nullable, support agent
"companyId" UUID REFERENCES "Company"("id"),-- nullable, company context
"lastActivityAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"firstResponseAt" TIMESTAMPTZ, -- set on first support reply
"resolvedAt" TIMESTAMPTZ,
"closedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT "ck_ticket_category" CHECK ("category" IN ('GENERAL','BILLING','TECHNICAL','ACCOUNT','FEATURE_REQUEST','BUG_REPORT')),
CONSTRAINT "ck_ticket_priority" CHECK ("priority" IN ('LOW','MEDIUM','HIGH','URGENT')),
CONSTRAINT "ck_ticket_status" CHECK ("status" IN ('OPEN','IN_PROGRESS','WAITING_ON_CUSTOMER','RESOLVED','CLOSED'))
);

-- Indexes for common query patterns
CREATE INDEX "idx_support_ticket_status" ON "SupportTicket"("status");
CREATE INDEX "idx_support_ticket_created_by" ON "SupportTicket"("createdById");
CREATE INDEX "idx_support_ticket_assigned_to" ON "SupportTicket"("assignedToId");
CREATE INDEX "idx_support_ticket_category" ON "SupportTicket"("category");
CREATE INDEX "idx_support_ticket_priority" ON "SupportTicket"("priority");
CREATE INDEX "idx_support_ticket_last_activity" ON "SupportTicket"("lastActivityAt" DESC);
CREATE INDEX "idx_support_ticket_company" ON "SupportTicket"("companyId");

-- Full-text search on subject
CREATE INDEX "idx_support_ticket_subject_gin" ON "SupportTicket" USING gin(to_tsvector('english', "subject"));

Ticket Number Generation: Auto-generated sequential format SUP-XXXXX (e.g., SUP-00001). Recommended approach: use a PostgreSQL sequence:

CREATE SEQUENCE support_ticket_number_seq START 1;

-- In application code or trigger:
-- ticketNumber = 'SUP-' || LPAD(nextval('support_ticket_number_seq')::text, 5, '0')

Table: TicketMessage

CREATE TABLE "TicketMessage" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"ticketId" UUID NOT NULL REFERENCES "SupportTicket"("id") ON DELETE CASCADE,
"senderId" UUID NOT NULL REFERENCES "User"("id"),
"senderRole" VARCHAR(20) NOT NULL, -- enum: USER, SUPPORT, SYSTEM
"content" TEXT NOT NULL, -- HTML
"isInternalNote" BOOLEAN NOT NULL DEFAULT FALSE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT "ck_message_sender_role" CHECK ("senderRole" IN ('USER','SUPPORT','SYSTEM'))
);

CREATE INDEX "idx_ticket_message_ticket" ON "TicketMessage"("ticketId");
CREATE INDEX "idx_ticket_message_sender" ON "TicketMessage"("senderId");
CREATE INDEX "idx_ticket_message_created" ON "TicketMessage"("createdAt");
CREATE INDEX "idx_ticket_message_internal" ON "TicketMessage"("ticketId", "isInternalNote");

Table: TicketAttachment

CREATE TABLE "TicketAttachment" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"messageId" UUID NOT NULL REFERENCES "TicketMessage"("id") ON DELETE CASCADE,
"filename" VARCHAR(500) NOT NULL,
"contentType" VARCHAR(200) NOT NULL,
"sizeBytes" BIGINT NOT NULL,
"objectPath" TEXT NOT NULL, -- S3 object key
"downloadUrl" TEXT, -- pre-signed URL (generated at query time)
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX "idx_ticket_attachment_message" ON "TicketAttachment"("messageId");

Note on downloadUrl: This field should NOT be stored permanently. Generate a pre-signed S3 URL at query resolution time with a short TTL (e.g., 15 minutes). The column can be omitted from the table and computed as a virtual/resolver field instead.

Table: TicketAuditLog

CREATE TABLE "TicketAuditLog" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"ticketId" UUID NOT NULL REFERENCES "SupportTicket"("id") ON DELETE CASCADE,
"actorId" UUID NOT NULL REFERENCES "User"("id"),
"action" VARCHAR(30) NOT NULL, -- enum: TicketAuditAction
"previousValue" TEXT, -- JSON string or plain value
"newValue" TEXT, -- JSON string or plain value
"metadata" JSONB, -- additional context
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT "ck_audit_action" CHECK ("action" IN (
'CREATED','STATUS_CHANGED','PRIORITY_CHANGED','ASSIGNED','UNASSIGNED',
'REASSIGNED','MESSAGE_SENT','INTERNAL_NOTE_ADDED','ATTACHMENT_ADDED',
'CATEGORY_CHANGED','RESOLVED','CLOSED','REOPENED'
))
);

CREATE INDEX "idx_ticket_audit_log_ticket" ON "TicketAuditLog"("ticketId");
CREATE INDEX "idx_ticket_audit_log_actor" ON "TicketAuditLog"("actorId");
CREATE INDEX "idx_ticket_audit_log_action" ON "TicketAuditLog"("action");
CREATE INDEX "idx_ticket_audit_log_created" ON "TicketAuditLog"("createdAt" DESC);

Table: FaqEntry

CREATE TABLE "FaqEntry" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL, -- HTML
"category" VARCHAR(30) NOT NULL, -- same enum as TicketCategory
"tags" TEXT[] NOT NULL DEFAULT '{}', -- searchable keywords
"sourceTicketId" UUID REFERENCES "SupportTicket"("id") ON DELETE SET NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PUBLISHED, ARCHIVED
"createdById" UUID NOT NULL REFERENCES "User"("id"),
"publishedAt" TIMESTAMPTZ,
"viewCount" INT NOT NULL DEFAULT 0,
"helpfulCount" INT NOT NULL DEFAULT 0,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT "ck_faq_category" CHECK ("category" IN ('GENERAL','BILLING','TECHNICAL','ACCOUNT','FEATURE_REQUEST','BUG_REPORT')),
CONSTRAINT "ck_faq_status" CHECK ("status" IN ('DRAFT','PUBLISHED','ARCHIVED'))
);

CREATE INDEX "idx_faq_entry_status" ON "FaqEntry"("status");
CREATE INDEX "idx_faq_entry_category" ON "FaqEntry"("category");
CREATE INDEX "idx_faq_entry_source" ON "FaqEntry"("sourceTicketId");
CREATE INDEX "idx_faq_entry_published" ON "FaqEntry"("publishedAt" DESC) WHERE "status" = 'PUBLISHED';

-- Full-text search on question and answer
CREATE INDEX "idx_faq_question_gin" ON "FaqEntry" USING gin(to_tsvector('english', "question"));
CREATE INDEX "idx_faq_answer_gin" ON "FaqEntry" USING gin(to_tsvector('english', "answer"));
CREATE INDEX "idx_faq_tags_gin" ON "FaqEntry" USING gin("tags");

Optional: FaqVote (De-duplication Table)

To prevent a user from voting "helpful" multiple times on the same FAQ:

CREATE TABLE "FaqVote" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"faqId" UUID NOT NULL REFERENCES "FaqEntry"("id") ON DELETE CASCADE,
"userId" UUID NOT NULL REFERENCES "User"("id"),
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT "uq_faq_vote" UNIQUE ("faqId", "userId")
);

Prisma Schema Additions

model SupportTicket {
id String @id @default(uuid()) @db.Uuid
ticketNumber String @unique
subject String
description String
category String
priority String @default("MEDIUM")
status String @default("OPEN")
createdById String @db.Uuid
assignedToId String? @db.Uuid
companyId String? @db.Uuid
lastActivityAt DateTime @default(now())
firstResponseAt DateTime?
resolvedAt DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

createdBy User @relation("TicketCreatedBy", fields: [createdById], references: [id])
assignedTo User? @relation("TicketAssignedTo", fields: [assignedToId], references: [id])
company Company? @relation(fields: [companyId], references: [id])
messages TicketMessage[]
auditLogs TicketAuditLog[]
sourceFaqs FaqEntry[]

@@index([status])
@@index([createdById])
@@index([assignedToId])
@@index([category])
@@index([priority])
@@index([lastActivityAt(sort: Desc)])
@@index([companyId])
}

model TicketMessage {
id String @id @default(uuid()) @db.Uuid
ticketId String @db.Uuid
senderId String @db.Uuid
senderRole String
content String
isInternalNote Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id])
attachments TicketAttachment[]

@@index([ticketId])
@@index([senderId])
@@index([createdAt])
}

model TicketAttachment {
id String @id @default(uuid()) @db.Uuid
messageId String @db.Uuid
filename String
contentType String
sizeBytes BigInt
objectPath String
createdAt DateTime @default(now())

message TicketMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)

@@index([messageId])
}

model TicketAuditLog {
id String @id @default(uuid()) @db.Uuid
ticketId String @db.Uuid
actorId String @db.Uuid
action String
previousValue String?
newValue String?
metadata Json?
createdAt DateTime @default(now())

ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
actor User @relation(fields: [actorId], references: [id])

@@index([ticketId])
@@index([actorId])
@@index([action])
@@index([createdAt(sort: Desc)])
}

model FaqEntry {
id String @id @default(uuid()) @db.Uuid
question String
answer String
category String
tags String[] @default([])
sourceTicketId String? @db.Uuid
status String @default("DRAFT")
createdById String @db.Uuid
publishedAt DateTime?
viewCount Int @default(0)
helpfulCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

sourceTicket SupportTicket? @relation(fields: [sourceTicketId], references: [id], onDelete: SetNull)
createdBy User @relation(fields: [createdById], references: [id])
votes FaqVote[]

@@index([status])
@@index([category])
@@index([sourceTicketId])
}

model FaqVote {
id String @id @default(uuid()) @db.Uuid
faqId String @db.Uuid
userId String @db.Uuid
createdAt DateTime @default(now())

faq FaqEntry @relation(fields: [faqId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])

@@unique([faqId, userId])
}

User model additions (add relations to existing User model):

model User {
// ... existing fields ...

// Support system relations
ticketsCreated SupportTicket[] @relation("TicketCreatedBy")
ticketsAssigned SupportTicket[] @relation("TicketAssignedTo")
ticketMessages TicketMessage[]
ticketAuditLogs TicketAuditLog[]
faqsCreated FaqEntry[]
faqVotes FaqVote[]
}

GraphQL Type Definitions (SDL)

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
MESSAGE_SENT
INTERNAL_NOTE_ADDED
ATTACHMENT_ADDED
CATEGORY_CHANGED
RESOLVED
CLOSED
REOPENED
}

enum FaqStatus {
DRAFT
PUBLISHED
ARCHIVED
}

enum TicketSortBy {
CREATED_AT
LAST_ACTIVITY
PRIORITY
}

enum SortOrder {
ASC
DESC
}

enum TicketEventType {
NEW_TICKET
NEW_MESSAGE
STATUS_CHANGED
ASSIGNED
PRIORITY_CHANGED
}

Object Types

Note: The frontend fragments target Output-suffixed type names (e.g., SupportTicketOutput, TicketMessageOutput). The backend's GraphQL SDL should use either the short names below OR the Output-suffixed names — just ensure the frontend fragments' on <TypeName> matches exactly. The current frontend expects Output-suffixed names for all entity types except TicketUser.

type TicketUser {
id: ID!
firstName: String!
lastName: String!
email: String
profileImageUrl: String
}

type SupportTicket {
id: ID!
ticketNumber: String!
subject: String!
description: String!
category: TicketCategory!
priority: TicketPriority!
status: TicketStatus!
createdById: ID!
createdBy: TicketUser
assignedToId: ID
assignedTo: TicketUser
companyId: ID
lastActivityAt: DateTime!
firstResponseAt: DateTime
resolvedAt: DateTime
closedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}

type TicketAttachment {
id: ID!
messageId: ID!
filename: String!
contentType: String!
sizeBytes: Int!
objectPath: String!
downloadUrl: String # Generated pre-signed S3 URL (15-min TTL)
createdAt: DateTime!
}

type TicketMessage {
id: ID!
ticketId: ID!
senderId: ID!
sender: TicketUser!
senderRole: TicketMessageSenderRole!
content: String!
isInternalNote: Boolean!
attachments: [TicketAttachment!]!
createdAt: DateTime!
updatedAt: DateTime!
}

type TicketAuditLog {
id: ID!
ticketId: ID!
actorId: ID!
actor: TicketUser!
action: TicketAuditAction!
previousValue: String
newValue: String
metadata: JSON
createdAt: DateTime!
}

type FaqEntry {
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 Response Types ─────────────────────────────────────────────────

type PaginatedTickets {
items: [SupportTicket!]!
total: Int!
page: Int!
limit: Int!
}

type PaginatedMessages {
items: [TicketMessage!]!
total: Int!
page: Int!
limit: Int!
}

type PaginatedAuditLogs {
items: [TicketAuditLog!]!
total: Int!
page: Int!
limit: Int!
}

type PaginatedFaqs {
items: [FaqEntry!]!
total: Int!
page: Int!
limit: Int!
}

# ─── Ticket Detail (composite return type) ────────────────────────────────────

type TicketDetail {
ticket: SupportTicket!
messages: [TicketMessage!]!
auditLog: [TicketAuditLog!] # Only returned for support/admin views
}

# ─── Dashboard & Stats Types ─────────────────────────────────────────────────

type CategoryCount {
category: TicketCategory!
count: Int!
}

type PriorityCount {
priority: TicketPriority!
count: Int!
}

type SupportDashboardStats {
totalOpen: Int!
totalInProgress: Int!
totalWaitingOnCustomer: Int!
totalResolved: Int!
totalClosed: Int!
avgFirstResponseTime: Float! # In minutes
avgResolutionTime: Float! # In minutes
ticketsByCategory: [CategoryCount!]!
ticketsByPriority: [PriorityCount!]!
}

type AgentPerformance {
agentId: ID!
agentName: String!
assignedCount: Int!
resolvedCount: Int!
avgResponseTime: Float! # In minutes
avgResolutionTime: Float! # In minutes
}

type AdminSupportOverview {
totalOpen: Int!
totalInProgress: Int!
totalWaitingOnCustomer: Int!
totalResolved: Int!
totalClosed: Int!
avgFirstResponseTime: Float!
avgResolutionTime: Float!
ticketsByCategory: [CategoryCount!]!
ticketsByPriority: [PriorityCount!]!
agentPerformance: [AgentPerformance!]!
}

type SupportAgent {
id: ID!
firstName: String!
lastName: String!
email: String!
profileImageUrl: String
assignedTicketCount: Int!
resolvedTicketCount: Int!
}

type UnreadTicketCount {
count: Int!
}

# ─── Subscription Event Types ────────────────────────────────────────────────

type TicketUpdateEvent {
ticket: SupportTicket!
newMessage: TicketMessage
type: TicketEventType!
}

type SupportTicketEventPayload {
ticket: SupportTicket!
type: TicketEventType!
}

# ─── Generic Mutation Response ────────────────────────────────────────────────

type MutationResponse {
success: Boolean!
message: String
}

type FaqHelpfulResponse {
id: ID!
helpfulCount: Int!
}

Input Types

input CreateSupportTicketInput {
subject: String!
description: String! # HTML from RichTextEditor
category: TicketCategory!
priority: TicketPriority # Defaults to MEDIUM if omitted
# attachments: [Upload!] # Optional: file uploads via apollo-upload-client
}

input SendTicketMessageInput {
ticketId: ID!
content: String! # HTML
# attachments: [Upload!] # Optional
}

input SendSupportReplyInput {
ticketId: ID!
content: String! # HTML
# attachments: [Upload!] # Optional
}

input AddInternalNoteInput {
ticketId: ID!
content: String! # HTML
}

input MyTicketsInput {
status: TicketStatus
page: Int # Default: 1
limit: Int # Default: 12
}

input SupportTicketsInput {
status: TicketStatus
priority: TicketPriority
category: TicketCategory
assignedToId: ID
search: String # Full-text search on subject
sortBy: TicketSortBy # Default: LAST_ACTIVITY
sortOrder: SortOrder # Default: DESC
page: Int # Default: 1
limit: Int # Default: 12
}

input AdminSupportAuditLogInput {
ticketId: ID
actorId: ID
action: TicketAuditAction
dateFrom: DateTime
dateTo: DateTime
page: Int # Default: 1
limit: Int # Default: 30
}

input CreateFaqInput {
question: String!
answer: String! # HTML
category: TicketCategory!
tags: [String!]
sourceTicketId: ID
status: FaqStatus # Default: DRAFT
}

input UpdateFaqInput {
question: String
answer: String
category: TicketCategory
tags: [String!]
status: FaqStatus
}

input PublishedFaqsInput {
category: TicketCategory
search: String
page: Int # Default: 1
limit: Int # Default: 12
}

input SupportFaqsInput {
status: FaqStatus
category: TicketCategory
search: String
page: Int # Default: 1
limit: Int # Default: 12
}

Queries

type Query {
# ─── User-Facing ─────────────────────────────────────────────────────
myTickets(input: MyTicketsInput): PaginatedTickets!
myTicketDetail(ticketId: ID!): TicketDetail!
myUnreadTicketCount: UnreadTicketCount!

# ─── Support Portal (requires support role or super admin) ──────────
supportTickets(input: SupportTicketsInput!): PaginatedTickets!
supportTicketDetail(ticketId: ID!): TicketDetail!
supportDashboardStats: SupportDashboardStats!

# ─── Admin (requires super admin) ──────────────────────────────────
adminSupportOverview: AdminSupportOverview!
adminListSupportAgents: [SupportAgent!]!
adminSupportAuditLog(input: AdminSupportAuditLogInput): PaginatedAuditLogs!

# ─── FAQ ─────────────────────────────────────────────────────────────
publishedFaqs(input: PublishedFaqsInput): PaginatedFaqs!
supportFaqs(input: SupportFaqsInput): PaginatedFaqs!
}

Mutations

type Mutation {
# ─── User-Facing ─────────────────────────────────────────────────────
createSupportTicket(input: CreateSupportTicketInput!): SupportTicket!
sendTicketMessage(input: SendTicketMessageInput!): TicketMessage!
closeMyTicket(ticketId: ID!): SupportTicket!
reopenMyTicket(ticketId: ID!): SupportTicket!

# ─── Support Portal ─────────────────────────────────────────────────
assignTicket(ticketId: ID!, agentId: ID!): SupportTicket!
updateTicketStatus(ticketId: ID!, status: TicketStatus!): SupportTicket!
updateTicketPriority(ticketId: ID!, priority: TicketPriority!): SupportTicket!
updateTicketCategory(ticketId: ID!, category: TicketCategory!): SupportTicket!
sendSupportReply(input: SendSupportReplyInput!): TicketMessage!
addInternalNote(input: AddInternalNoteInput!): TicketMessage!

# ─── Admin ──────────────────────────────────────────────────────────
adminAssignSupportRole(userId: ID!): MutationResponse!
adminRevokeSupportRole(userId: ID!): MutationResponse!

# ─── FAQ ─────────────────────────────────────────────────────────────
createFaqEntry(input: CreateFaqInput!): FaqEntry!
updateFaqEntry(id: ID!, input: UpdateFaqInput!): FaqEntry!
deleteFaqEntry(id: ID!): MutationResponse!
markFaqHelpful(id: ID!): FaqHelpfulResponse!
}

Subscriptions

type Subscription {
# User receives updates for their own tickets
myTicketUpdated(ticketId: ID): TicketUpdateEvent!

# Support portal receives events for all tickets (new tickets, replies, etc.)
supportTicketEvent: SupportTicketEventPayload!
}

Resolver Specifications

User-Facing Queries

myTickets(input: MyTicketsInput): PaginatedTickets!

AspectDetail
AuthAuthenticated user
LogicQuery SupportTicket WHERE createdById = currentUser.id, optionally filtered by status. Order by lastActivityAt DESC.
PaginationOffset-based: page (default 1), limit (default 12). Return total count.
RelationsNo eager loading of createdBy/assignedTo -- use list fragment fields only.

myTicketDetail(ticketId: ID!): TicketDetail!

AspectDetail
AuthAuthenticated user, must be the ticket creator (createdById = currentUser.id)
LogicFetch ticket with full detail fields. Fetch messages for this ticket.
CRITICAL: Internal notes filteringFilter out messages WHERE isInternalNote = true. Users must never see internal notes.
Audit logDo NOT return auditLog (return null). Users do not need audit history.
RelationsEager load createdBy, assignedTo on ticket. Eager load sender, attachments on messages.
ErrorThrow FORBIDDEN if the user is not the ticket creator. Throw NOT_FOUND if ticket does not exist.

myUnreadTicketCount: UnreadTicketCount!

AspectDetail
AuthAuthenticated user
LogicCount tickets WHERE createdById = currentUser.id AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_ON_CUSTOMER') AND there exists a message with senderRole = 'SUPPORT' AND createdAt > lastReadAt.
Alternative (simpler v1)Count tickets WHERE createdById = currentUser.id AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_ON_CUSTOMER', 'RESOLVED') AND lastActivityAt > user's last visit to /support. This requires a lastSupportVisitAt column on User, or a simpler approach: count tickets in non-CLOSED status that have any support reply.
NoteThe exact "unread" mechanism is flexible. A simple initial implementation could just count non-closed tickets.

Support Portal Queries

supportTickets(input: SupportTicketsInput!): PaginatedTickets!

AspectDetail
AuthSupport agent OR super admin
LogicQuery all tickets with optional filters: status, priority, category, assignedToId, search (full-text on subject).
SortingsortBy (default LAST_ACTIVITY): CREATED_AT maps to createdAt, LAST_ACTIVITY maps to lastActivityAt, PRIORITY maps to priority with custom ordering (URGENT > HIGH > MEDIUM > LOW). sortOrder defaults to DESC.
SearchUse PostgreSQL full-text search: to_tsvector('english', subject) @@ plainto_tsquery('english', :search).
PaginationOffset-based: page (default 1), limit (default 12).
RelationsEager load createdBy and assignedTo for the ticket list.

supportTicketDetail(ticketId: ID!): TicketDetail!

AspectDetail
AuthSupport agent OR super admin
LogicFetch ticket with all detail fields. Fetch ALL messages including internal notes (isInternalNote = true). Fetch audit log entries ordered by createdAt ASC.
Key difference from myTicketDetailInternal notes are INCLUDED. Audit log IS returned.
RelationsEager load createdBy, assignedTo on ticket. Eager load sender, attachments on messages. Eager load actor on audit logs.

supportDashboardStats: SupportDashboardStats!

AspectDetail
AuthSupport agent OR super admin
LogicAggregate queries on SupportTicket table.
FieldstotalOpen, totalInProgress, totalWaitingOnCustomer, totalResolved, totalClosed -- simple COUNT with WHERE clause on status. avgFirstResponseTime -- average of firstResponseAt - createdAt in minutes for tickets where firstResponseAt IS NOT NULL. avgResolutionTime -- average of resolvedAt - createdAt in minutes for tickets where resolvedAt IS NOT NULL. ticketsByCategory -- GROUP BY category with count. ticketsByPriority -- GROUP BY priority with count.

Admin Queries

adminSupportOverview: AdminSupportOverview!

AspectDetail
AuthSuper admin only
LogicSame as supportDashboardStats plus agentPerformance array.
Agent performanceFor each user with the "support" role: assignedCount (tickets currently assigned), resolvedCount (tickets they resolved), avgResponseTime (average time to first reply on assigned tickets), avgResolutionTime (average resolvedAt - assignedAt). agentName = firstName + ' ' + lastName.

adminListSupportAgents: [SupportAgent!]!

AspectDetail
AuthSuper admin only
LogicQuery all users who have the "support" role. For each, count assigned and resolved tickets.
NoteThe "support" role is identified by checking the Role table for a role with name = 'support', then joining through UserCompanyRole or a new UserRole table (see Support Role section).

adminSupportAuditLog(input: AdminSupportAuditLogInput): PaginatedAuditLogs!

AspectDetail
AuthSuper admin only
LogicQuery TicketAuditLog with optional filters: ticketId, actorId, action, dateFrom/dateTo. Order by createdAt DESC.
PaginationOffset-based: page (default 1), limit (default 30).
RelationsEager load actor on each audit log entry.

User-Facing Mutations

createSupportTicket(input: CreateSupportTicketInput!): SupportTicket!

AspectDetail
AuthAuthenticated user
Logic1. Generate ticket number (SUP-XXXXX). 2. Create SupportTicket record with createdById = currentUser.id, status = OPEN, priority = input.priority ?? MEDIUM, lastActivityAt = NOW(). 3. Set companyId = currentUser.selectedCompanyId (nullable -- captures company context if the user is in company mode).
Audit logCreate TicketAuditLog entry: action = CREATED, actorId = currentUser.id, newValue = status ('OPEN').
NotificationPublish supportTicketEvent subscription event with type = NEW_TICKET. Send notification to all support agents (see Notification Integration).
Validationsubject: required, max 500 chars. description: required, non-empty. category: required, must be valid enum.

sendTicketMessage(input: SendTicketMessageInput!): TicketMessage!

AspectDetail
AuthAuthenticated user, must be the ticket creator
Logic1. Verify ticket exists and createdById = currentUser.id. 2. Create TicketMessage with senderRole = USER, isInternalNote = false. 3. Update ticket: lastActivityAt = NOW(). 4. Automatic status transition: If ticket status = WAITING_ON_CUSTOMER, change to IN_PROGRESS. If ticket status = RESOLVED, change to OPEN (reopen).
Audit logCreate entry: action = MESSAGE_SENT. If status changed, create additional entry: action = STATUS_CHANGED (or REOPENED if was RESOLVED), previousValue = oldStatus, newValue = newStatus.
NotificationPublish supportTicketEvent with type = NEW_MESSAGE. If ticket is assigned, notify the assigned agent. Otherwise, notify all support agents. Publish myTicketUpdated with the new message for real-time UI update.
ErrorThrow FORBIDDEN if user is not the ticket creator. Throw BAD_REQUEST if ticket status is CLOSED (user must reopen first).

closeMyTicket(ticketId: ID!): SupportTicket!

AspectDetail
AuthAuthenticated user, must be the ticket creator
LogicSet status = CLOSED, closedAt = NOW(), lastActivityAt = NOW().
Audit logaction = CLOSED, previousValue = oldStatus, newValue = 'CLOSED'.
NotificationNotify assigned agent (if any). Publish supportTicketEvent with type = STATUS_CHANGED.

reopenMyTicket(ticketId: ID!): SupportTicket!

AspectDetail
AuthAuthenticated user, must be the ticket creator
LogicOnly allowed when status IN (RESOLVED, CLOSED). Set status = OPEN, clear resolvedAt and closedAt, set lastActivityAt = NOW().
Audit logaction = REOPENED, previousValue = oldStatus, newValue = 'OPEN'.
NotificationNotify assigned agent (if any). Publish supportTicketEvent with type = STATUS_CHANGED.

Support Portal Mutations

assignTicket(ticketId: ID!, agentId: ID!): SupportTicket!

AspectDetail
AuthSupport agent OR super admin
Logic1. Verify agentId refers to a user with the "support" role (or is a super admin). 2. If ticket was previously unassigned: set assignedToId = agentId, create audit with action = ASSIGNED. 3. If ticket was assigned to someone else: create audit with action = REASSIGNED, previousValue = oldAgentId, newValue = agentId. 4. If ticket status = OPEN, transition to IN_PROGRESS. 5. Update lastActivityAt.
Audit logASSIGNED or REASSIGNED with agent IDs. Plus STATUS_CHANGED if status transitioned.
NotificationNotify the newly assigned agent. If reassigned, also notify the previously assigned agent.

updateTicketStatus(ticketId: ID!, status: TicketStatus!): SupportTicket!

AspectDetail
AuthSupport agent OR super admin
LogicValidate status transition (see Ticket Lifecycle). Update status, lastActivityAt = NOW(). If status = RESOLVED, set resolvedAt = NOW(). If status = CLOSED, set closedAt = NOW(). If transitioning away from RESOLVED/CLOSED, clear those timestamps.
Audit logaction = STATUS_CHANGED (or RESOLVED/CLOSED/REOPENED as appropriate), previousValue = oldStatus, newValue = newStatus.
NotificationNotify ticket creator. Publish myTicketUpdated event.

updateTicketPriority(ticketId: ID!, priority: TicketPriority!): SupportTicket!

AspectDetail
AuthSupport agent OR super admin
LogicUpdate priority, lastActivityAt = NOW().
Audit logaction = PRIORITY_CHANGED, previousValue = oldPriority, newValue = newPriority.

updateTicketCategory(ticketId: ID!, category: TicketCategory!): SupportTicket!

AspectDetail
AuthSupport agent OR super admin
LogicUpdate category, lastActivityAt = NOW().
Audit logaction = CATEGORY_CHANGED, previousValue = oldCategory, newValue = newCategory.

sendSupportReply(input: SendSupportReplyInput!): TicketMessage!

AspectDetail
AuthSupport agent OR super admin
Logic1. Create TicketMessage with senderRole = SUPPORT, isInternalNote = false, senderId = currentUser.id. 2. Update ticket: lastActivityAt = NOW(). 3. First response tracking: If firstResponseAt IS NULL, set firstResponseAt = NOW(). 4. Automatic status transition: Set status = WAITING_ON_CUSTOMER (support has replied, now waiting for user).
IMPORTANT -- Sender dataInclude the real support agent's sender data (id, firstName, lastName, profileImageUrl). The frontend handles masking this to "AiQlick Support" in user-facing views. The backend always returns real data.
Audit logaction = MESSAGE_SENT. If status changed, additional action = STATUS_CHANGED entry.
NotificationNotify ticket creator (the user). Publish myTicketUpdated event with the new message.

addInternalNote(input: AddInternalNoteInput!): TicketMessage!

AspectDetail
AuthSupport agent OR super admin
LogicCreate TicketMessage with senderRole = SUPPORT, isInternalNote = true, senderId = currentUser.id. Update lastActivityAt. Does NOT change ticket status.
Key behaviorInternal notes are visible ONLY via supportTicketDetail query. The myTicketDetail query filters them out.
Audit logaction = INTERNAL_NOTE_ADDED.
NotificationNotify other support agents assigned to or watching the ticket (NOT the ticket creator).

Admin Mutations

adminAssignSupportRole(userId: ID!): MutationResponse!

AspectDetail
AuthSuper admin only
LogicAssign the "support" role to the specified user. This can leverage the existing RBAC system. See Support Role section for implementation options.
ValidationVerify user exists. Verify user does not already have the "support" role.
Response{ success: true, message: "Support role assigned successfully" }

adminRevokeSupportRole(userId: ID!): MutationResponse!

AspectDetail
AuthSuper admin only
LogicRemove the "support" role from the specified user. Optionally: unassign the agent from any currently assigned tickets (or reassign to null).
ValidationVerify user exists. Verify user currently has the "support" role.
Response{ success: true, message: "Support role revoked successfully" }

FAQ Mutations

createFaqEntry(input: CreateFaqInput!): FaqEntry!

AspectDetail
AuthSupport agent OR super admin
LogicCreate FaqEntry. If status = PUBLISHED, set publishedAt = NOW().
Validationquestion: required, non-empty. answer: required, non-empty. category: required, valid enum.

updateFaqEntry(id: ID!, input: UpdateFaqInput!): FaqEntry!

AspectDetail
AuthSupport agent OR super admin
LogicUpdate specified fields. If status changes to PUBLISHED and publishedAt IS NULL, set publishedAt = NOW().

deleteFaqEntry(id: ID!): MutationResponse!

AspectDetail
AuthSuper admin only
LogicHard delete the FAQ entry and associated votes.

markFaqHelpful(id: ID!): FaqHelpfulResponse!

AspectDetail
AuthAuthenticated user
Logic1. Check FaqVote table for existing vote by this user on this FAQ. 2. If no existing vote: insert FaqVote, increment FaqEntry.helpfulCount. 3. If already voted: no-op (return current count, do not error).
Response{ id: faqId, helpfulCount: updatedCount }

FAQ Queries

publishedFaqs(input: PublishedFaqsInput): PaginatedFaqs!

AspectDetail
AuthAuthenticated user (any role)
LogicQuery FaqEntry WHERE status = 'PUBLISHED'. Optional filters: category, search (full-text on question + answer + tags via to_tsvector). Increment viewCount on each FAQ returned (or use a separate tracking mechanism). Order by helpfulCount DESC, publishedAt DESC.
PaginationOffset-based: page (default 1), limit (default 12).

supportFaqs(input: SupportFaqsInput): PaginatedFaqs!

AspectDetail
AuthSupport agent OR super admin
LogicQuery FaqEntry with all statuses visible. Optional filters: status, category, search. Order by updatedAt DESC.
PaginationOffset-based: page (default 1), limit (default 12).

Subscriptions

myTicketUpdated(ticketId: ID): TicketUpdateEvent!

AspectDetail
AuthAuthenticated user (only receives events for their own tickets)
LogicIf ticketId is provided, filter to that specific ticket. Otherwise, emit events for all tickets created by the authenticated user.
Events emittedWhen a support reply is sent to one of the user's tickets. When ticket status changes. When ticket is assigned/reassigned.
Payloadticket: updated ticket data. newMessage: the new message (if event was triggered by a message). type: event type enum.
FilteringServer-side: only emit to the connection of the user who created the ticket. Never emit internal notes through this subscription.

supportTicketEvent: SupportTicketEventPayload!

AspectDetail
AuthSupport agent OR super admin
LogicEmits events for ALL tickets (not filtered to a specific user's tickets).
Events emittedNew ticket created. New user message on any ticket. Status changes. Priority changes.
Payloadticket: the ticket that was affected. type: event type enum.

Support Role (RBAC Integration)

The support role is implemented as a global role, not a company role.

Current state:

  • Role.name = "support" exists in RDS.
  • Support assignments use UserRole, not UserCompanyRole.
  • SupportRoleService upserts the support role at backend boot.
  • SupportAgentGuard allows users assigned the global support role, and also allows platform super admins.
  • User.isSupportAgent is a computed GraphQL field resolved from the global support assignment.
  • Company role mutations remain company-scoped and should not be used for support-agent assignment.

Use the super-admin mutations adminAssignSupportRole(userId) and adminRevokeSupportRole(userId) for support access. See Support Overview and Roles & Permissions for the current backend and RDS details.


Ticket Lifecycle

Status Transition Diagram

                              +-----------+
| |
+-------->| OPEN |<---------+
| | | |
| +-----+-----+ |
| | |
| | Agent assigns |
| | or picks up |
| v |
| +-----------+ |
Customer | | | | Customer replies
replies +---------|IN_PROGRESS| | to RESOLVED
to | | | | ticket (reopen)
WAITING | +-----+-----+ |
| | |
| | Agent replies |
| v |
| +-----------------+ |
| | WAITING_ON | |
+-----| CUSTOMER | |
+-----------------+ |
| |
| Agent resolves |
v |
+-----------+ |
| |--------------+
| RESOLVED |
| |---+
+-----+-----+ |
| | Auto-close
| | after 7 days
User/agent | |
closes v v
+-----------+
| |
| CLOSED |
| |
+-----------+

Status Transition Rules

FromToTriggered ByNotes
(none)OPENUser creates ticketInitial state
OPENIN_PROGRESSAgent assigns/picks up ticketAutomatic on assignment
IN_PROGRESSWAITING_ON_CUSTOMERAgent sends replyAutomatic on support reply
WAITING_ON_CUSTOMERIN_PROGRESSCustomer sends messageAutomatic on customer reply
IN_PROGRESSRESOLVEDAgent resolvesManual action
WAITING_ON_CUSTOMERRESOLVEDAgent resolvesManual action
RESOLVEDOPENCustomer repliesAutomatic reopen
RESOLVEDCLOSEDAuto-close after 7 daysScheduled job
RESOLVEDCLOSEDUser or agent closesManual action
OPENCLOSEDUser or agent closesManual action
IN_PROGRESSCLOSEDAgent closesManual action
WAITING_ON_CUSTOMERCLOSEDAgent closesManual action
CLOSEDOPENUser reopensVia reopenMyTicket mutation

Automatic Status Changes

These status changes happen automatically as side effects of other actions:

  1. Agent assigns ticket: If status = OPEN, transition to IN_PROGRESS
  2. Agent sends reply (sendSupportReply): Transition to WAITING_ON_CUSTOMER
  3. Customer sends message (sendTicketMessage): If status = WAITING_ON_CUSTOMER, transition to IN_PROGRESS. If status = RESOLVED, transition to OPEN (reopen)
  4. Customer replies to CLOSED ticket: Reject with error. User must explicitly reopen via reopenMyTicket

Auto-Close Scheduled Job

A background job (cron) should run periodically (e.g., every hour) to close stale resolved tickets:

UPDATE "SupportTicket"
SET "status" = 'CLOSED',
"closedAt" = NOW(),
"lastActivityAt" = NOW(),
"updatedAt" = NOW()
WHERE "status" = 'RESOLVED'
AND "resolvedAt" < NOW() - INTERVAL '7 days';

For each auto-closed ticket, create an audit log entry:

INSERT INTO "TicketAuditLog" ("id", "ticketId", "actorId", "action", "previousValue", "newValue", "metadata", "createdAt")
VALUES (
gen_random_uuid(),
:ticketId,
:systemUserId, -- Use a dedicated "system" user ID
'CLOSED',
'RESOLVED',
'CLOSED',
'{"reason": "auto_close_7_days"}',
NOW()
);

Also generate a system message:

INSERT INTO "TicketMessage" ("id", "ticketId", "senderId", "senderRole", "content", "isInternalNote", "createdAt", "updatedAt")
VALUES (
gen_random_uuid(),
:ticketId,
:systemUserId,
'SYSTEM',
'This ticket was automatically closed after 7 days of inactivity in resolved status.',
false,
NOW(),
NOW()
);

Authorization Matrix

Role Definitions

RoleHow Identified
UserAny authenticated user (has valid JWT)
Support AgentUser with isSupportAgent = true (has "support" role via UserRole table)
Super AdminUser with isSuperAdmin = true (existing mechanism)

Query Authorization

QueryUserSupport AgentSuper Admin
myTicketsOwn tickets onlyOwn tickets onlyOwn tickets only
myTicketDetailOwn tickets onlyOwn tickets onlyOwn tickets only
myUnreadTicketCountYesYesYes
supportTicketsDENIEDAll ticketsAll tickets
supportTicketDetailDENIEDAll tickets (with internal notes + audit log)All tickets (with internal notes + audit log)
supportDashboardStatsDENIEDYesYes
adminSupportOverviewDENIEDDENIEDYes
adminListSupportAgentsDENIEDDENIEDYes
adminSupportAuditLogDENIEDDENIEDYes
publishedFaqsYesYesYes
supportFaqsDENIEDYesYes

Mutation Authorization

MutationUserSupport AgentSuper Admin
createSupportTicketYesYesYes
sendTicketMessageOwn tickets onlyOwn tickets only (as user)Own tickets only (as user)
closeMyTicketOwn tickets onlyOwn tickets onlyOwn tickets only
reopenMyTicketOwn tickets onlyOwn tickets onlyOwn tickets only
assignTicketDENIEDYesYes
updateTicketStatusDENIEDYesYes
updateTicketPriorityDENIEDYesYes
updateTicketCategoryDENIEDYesYes
sendSupportReplyDENIEDYesYes
addInternalNoteDENIEDYesYes
adminAssignSupportRoleDENIEDDENIEDYes
adminRevokeSupportRoleDENIEDDENIEDYes
createFaqEntryDENIEDYesYes
updateFaqEntryDENIEDYesYes
deleteFaqEntryDENIEDDENIEDYes
markFaqHelpfulYesYesYes

Subscription Authorization

SubscriptionUserSupport AgentSuper Admin
myTicketUpdatedOwn tickets onlyOwn tickets onlyOwn tickets only
supportTicketEventDENIEDYesYes

Authorization Implementation Pattern

Use resolver middleware or directive-based auth. Example with middleware:

// Middleware helper
const requireAuth = (resolver) => (parent, args, context) => {
if (!context.user) throw new AuthenticationError('Not authenticated');
return resolver(parent, args, context);
};

const requireSupport = (resolver) => (parent, args, context) => {
if (!context.user) throw new AuthenticationError('Not authenticated');
if (!context.user.isSupportAgent && !context.user.isSuperAdmin) {
throw new ForbiddenError('Support agent or admin access required');
}
return resolver(parent, args, context);
};

const requireSuperAdmin = (resolver) => (parent, args, context) => {
if (!context.user) throw new AuthenticationError('Not authenticated');
if (!context.user.isSuperAdmin) {
throw new ForbiddenError('Super admin access required');
}
return resolver(parent, args, context);
};

Notification Integration

When to Send Notifications

EventWho Gets NotifiedChannel
New ticket createdAll support agentsIn-app notification + subscription event
User sends messageAssigned agent (or all agents if unassigned)In-app notification + subscription event
Support reply sentTicket creator (user)In-app notification + subscription event + email (optional)
Ticket assignedNewly assigned agentIn-app notification
Ticket reassignedNew agent + old agentIn-app notification
Status changed to RESOLVEDTicket creatorIn-app notification + subscription event
Status changed to CLOSEDTicket creatorIn-app notification
Ticket reopenedAssigned agent (if any)In-app notification
Internal note addedOther support agents on the ticketIn-app notification (support portal only)
Priority changed to URGENTAll support agentsIn-app notification
Auto-closed (7 days)Ticket creatorIn-app notification

In-App Notification Format

Use the existing notification system (which polls via UNREAD_NOTIFICATION_COUNT). Create notification records in the existing Notification table (if it exists) or a new one.

Suggested notification data structure:

{
type: 'SUPPORT_TICKET',
action: 'NEW_REPLY' | 'STATUS_CHANGED' | 'ASSIGNED' | 'NEW_TICKET',
entityId: ticketId,
entityType: 'SupportTicket',
title: 'New reply on ticket SUP-00421',
body: 'A support agent has replied to your ticket: "Login issue"',
recipientId: userId,
read: false,
createdAt: new Date()
}

Subscription Events (Real-Time)

Use the existing GraphQL subscription infrastructure (via graphql-ws). Publish events using PubSub:

// When a support reply is sent:
pubsub.publish('MY_TICKET_UPDATED', {
myTicketUpdated: {
ticket: updatedTicket,
newMessage: newMessage,
type: 'NEW_MESSAGE'
}
});

// Filter: only deliver to the ticket creator's connection
withFilter(
() => pubsub.asyncIterator('MY_TICKET_UPDATED'),
(payload, variables, context) => {
const ticket = payload.myTicketUpdated.ticket;
// Only emit to the ticket creator
if (ticket.createdById !== context.user.id) return false;
// If ticketId filter provided, match it
if (variables.ticketId && ticket.id !== variables.ticketId) return false;
return true;
}
);

// When any ticket event occurs (for support portal):
pubsub.publish('SUPPORT_TICKET_EVENT', {
supportTicketEvent: {
ticket: updatedTicket,
type: 'NEW_MESSAGE' // or 'NEW_TICKET', 'STATUS_CHANGED', etc.
}
});

// Filter: only deliver to support agents and super admins
withFilter(
() => pubsub.asyncIterator('SUPPORT_TICKET_EVENT'),
(payload, variables, context) => {
return context.user.isSupportAgent || context.user.isSuperAdmin;
}
);

Email Notifications (Optional, Phase 2+)

For critical events (new support reply to user, URGENT priority ticket), optionally send email notifications. This can be implemented later via a notification queue (SQS, etc.).


API Design Notes

Pagination

All paginated endpoints use offset-based pagination (consistent with the rest of the AiQlick application).

// Input
interface PaginationInput {
page?: number; // 1-indexed, default 1
limit?: number; // default varies: 12 for grids, 30 for tables/logs
}

// Output
interface PaginatedResponse<T> {
items: T[];
total: number; // Total count (for calculating total pages)
page: number; // Current page
limit: number; // Items per page
}

SQL pattern:

SELECT *, COUNT(*) OVER() as total
FROM "SupportTicket"
WHERE ...
ORDER BY "lastActivityAt" DESC
LIMIT :limit
OFFSET (:page - 1) * :limit;

Default Sort Orders

EndpointDefault SortDefault Order
myTicketslastActivityAtDESC
supportTicketslastActivityAtDESC
adminSupportAuditLogcreatedAtDESC
publishedFaqshelpfulCount then publishedAtDESC
supportFaqsupdatedAtDESC
Ticket messagescreatedAtASC (chronological)
Audit log entriescreatedAtASC (chronological)

Filtering

All filter parameters are optional. When omitted, no filter is applied for that field. Multiple filters are combined with AND logic.

Full-text search (for search parameter on tickets and FAQs):

-- Tickets: search on subject
WHERE to_tsvector('english', "subject") @@ plainto_tsquery('english', :search)

-- FAQs: search on question, answer, and tags
WHERE to_tsvector('english', "question" || ' ' || "answer") @@ plainto_tsquery('english', :search)
OR :search = ANY("tags")

Error Codes

CodeWhen
UNAUTHENTICATEDNo valid JWT token
FORBIDDENUser lacks required role or is not the ticket owner
NOT_FOUNDTicket, message, or FAQ entry does not exist
BAD_REQUESTInvalid input (e.g., invalid status transition, sending message to CLOSED ticket)
CONFLICTDuplicate operation (e.g., assigning a role the user already has)

Rate Limiting Recommendations

OperationSuggested Limit
createSupportTicket5 per hour per user
sendTicketMessage20 per hour per user
markFaqHelpful30 per hour per user
publishedFaqs60 per minute per user

File Upload (Attachments)

Ticket message attachments follow the existing S3 upload pattern used elsewhere in the application:

  1. Client uploads file via apollo-upload-client (multipart/form-data)
  2. Server receives file in resolver, uploads to S3 at path: support/tickets/{ticketId}/{messageId}/{filename}
  3. Store objectPath in TicketAttachment table
  4. Generate pre-signed download URL at query time (15-minute TTL)

File size limits:

  • Max file size: 10MB per file
  • Max files per message: 5
  • Allowed types: images (jpg, png, gif, webp), documents (pdf, doc, docx, xls, xlsx), text (txt, csv)

Frontend Integration Notes

Error Handling

All frontend hooks handle GraphQL errors gracefully. If a query/mutation fails (e.g., resolver not implemented yet), the hooks return empty arrays/null values and the UI shows appropriate empty states. Components display loading spinners while queries are in flight and error messages when operations fail.

WHO_AM_I Query

The frontend requests isSupportAgent in the WHO_AM_I query (graphql/operations/user/queries.ts). The backend returns this computed field from UserSupportAgentFieldResolver.

query WhoAmI {
whoAmI {
# ... existing fields ...
isSuperAdmin
isSupportAgent # <-- Must be returned
}
}

Message Sender Identity

Messages from support agents should include real sender data (id, firstName, lastName, profileImageUrl). The frontend handles masking agent identity for user-facing views:

  • In /support and SupportDrawer (user views): support agent messages display as "AiQlick Support" with a branded avatar, and user messages are right-aligned
  • In /support-portal (support views): real agent names and avatars are shown, and support agent messages are right-aligned (reversed alignment)

This keeps the backend simple -- always return the real sender. The frontend code for masking and alignment is in the TicketConversation component.

Ticket Description Display

The ticket description (HTML from the creation form) is displayed as the first item in the conversation thread, before any messages. It shows the ticket creator's avatar, a "Description" chip, and the description content rendered as HTML. This ensures support agents always see the initial context.

My Tickets Filtering (assignedToId)

The "My Tickets" view in the support portal passes assignedToId: currentUser.id via the SupportTicketsInput. The backend must filter tickets by assignedToId when this field is provided.

FAQ Search Debouncing

Frontend FAQ search inputs use a 300ms debounce before sending queries. The backend should still handle rapid queries gracefully in case debouncing fails client-side.

Assign to Me Visibility

The "Assign to Me" button is hidden when ticket.assignedToId === currentUser.id. The backend assignTicket mutation should be idempotent — assigning a ticket to the same agent is a no-op.

Internal Notes Filtering

This is the single most important backend behavior to get right:

  • myTicketDetail resolver: MUST filter out messages where isInternalNote = true
  • supportTicketDetail resolver: MUST include messages where isInternalNote = true

If internal notes leak to the user-facing query, they will be visible to the ticket creator. This is a data leak.

Fragment Compatibility

The frontend defines these fragments that the backend GraphQL output types must match. Note: backend types use the Output suffix convention (e.g., SupportTicketOutput), except TicketUser which is an embedded type without the suffix.

FragmentExpected GraphQL TypeKey Fields
TicketUserFieldsTicketUserid, firstName, lastName, email, profileImageUrl
SupportTicketFieldsSupportTicketOutputid, ticketNumber, subject, description, category, priority, status, createdById, createdBy { ...TicketUserFields }, assignedToId, assignedTo { ...TicketUserFields }, companyId, lastActivityAt, firstResponseAt, resolvedAt, closedAt, createdAt, updatedAt
TicketAttachmentFieldsTicketAttachmentOutputid, messageId, filename, contentType, sizeBytes, objectPath, downloadUrl, createdAt
TicketMessageFieldsTicketMessageOutputid, ticketId, senderId, sender { ...TicketUserFields }, senderRole, content, isInternalNote, attachments { ...TicketAttachmentFields }, createdAt, updatedAt
TicketAuditLogFieldsTicketAuditLogOutputid, ticketId, actorId, actor { ...TicketUserFields }, action, previousValue, newValue, metadata, createdAt
FaqEntryFieldsFaqEntryOutputid, question, answer, category, tags, sourceTicketId, status, createdById, publishedAt, viewCount, helpfulCount, createdAt, updatedAt

Important: The frontend uses a single merged SupportTicketFields fragment for both list and detail views (no separate list/detail fragments). This fragment includes all fields including description, createdBy, and assignedTo.

Subscription Transport

Subscriptions use the main backend GraphQL WebSocket (NOT the AI service). The frontend connects via the existing Apollo subscription infrastructure using graphql-ws.

Audit Log Entries -- Complete Reference

Every mutation that modifies a ticket should create an audit log entry. Here is the complete mapping:

MutationAudit ActionpreviousValuenewValuemetadata
createSupportTicketCREATEDnull"OPEN"{ category, priority }
sendTicketMessageMESSAGE_SENTnullnull{ messageId }
sendTicketMessage (status change)STATUS_CHANGED or REOPENEDold statusnew status{ trigger: "customer_reply" }
closeMyTicketCLOSEDold status"CLOSED"null
reopenMyTicketREOPENEDold status"OPEN"null
assignTicket (first)ASSIGNEDnullagentId{ agentName }
assignTicket (reassign)REASSIGNEDold agentIdnew agentId{ oldAgentName, newAgentName }
updateTicketStatusSTATUS_CHANGEDold statusnew statusnull
updateTicketStatus (RESOLVED)RESOLVEDold status"RESOLVED"null
updateTicketStatus (CLOSED)CLOSEDold status"CLOSED"null
updateTicketPriorityPRIORITY_CHANGEDold prioritynew prioritynull
updateTicketCategoryCATEGORY_CHANGEDold categorynew categorynull
sendSupportReplyMESSAGE_SENTnullnull{ messageId }
sendSupportReply (status change)STATUS_CHANGEDold status"WAITING_ON_CUSTOMER"{ trigger: "support_reply" }
addInternalNoteINTERNAL_NOTE_ADDEDnullnull{ messageId }
Auto-close (cron)CLOSED"RESOLVED""CLOSED"{ reason: "auto_close_7_days" }