Support/Help Desk System -- Backend Specification
Status: Frontend fully implemented. Backend implementation required. Frontend types:
lib/types/support.tsFrontend GraphQL operations:graphql/operations/support/(queries, mutations, subscriptions) Frontend fragments:graphql/operations/schema/support/fragments.ts
Table of Contents
- Overview
- Database Schema
- GraphQL Type Definitions (SDL)
- Resolver Specifications
- Support Role (RBAC Integration)
- Ticket Lifecycle
- Authorization Matrix
- Notification Integration
- API Design Notes
- 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:
| Tier | Audience | Route |
|---|---|---|
| User-facing | All authenticated users | /support, sidebar drawer |
| Support Portal | Support agents + super admins | /support-portal |
| Admin Panel | Super 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 exceptTicketUser.
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!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user |
| Logic | Query SupportTicket WHERE createdById = currentUser.id, optionally filtered by status. Order by lastActivityAt DESC. |
| Pagination | Offset-based: page (default 1), limit (default 12). Return total count. |
| Relations | No eager loading of createdBy/assignedTo -- use list fragment fields only. |
myTicketDetail(ticketId: ID!): TicketDetail!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user, must be the ticket creator (createdById = currentUser.id) |
| Logic | Fetch ticket with full detail fields. Fetch messages for this ticket. |
| CRITICAL: Internal notes filtering | Filter out messages WHERE isInternalNote = true. Users must never see internal notes. |
| Audit log | Do NOT return auditLog (return null). Users do not need audit history. |
| Relations | Eager load createdBy, assignedTo on ticket. Eager load sender, attachments on messages. |
| Error | Throw FORBIDDEN if the user is not the ticket creator. Throw NOT_FOUND if ticket does not exist. |
myUnreadTicketCount: UnreadTicketCount!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user |
| Logic | Count 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. |
| Note | The exact "unread" mechanism is flexible. A simple initial implementation could just count non-closed tickets. |
Support Portal Queries
supportTickets(input: SupportTicketsInput!): PaginatedTickets!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Query all tickets with optional filters: status, priority, category, assignedToId, search (full-text on subject). |
| Sorting | sortBy (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. |
| Search | Use PostgreSQL full-text search: to_tsvector('english', subject) @@ plainto_tsquery('english', :search). |
| Pagination | Offset-based: page (default 1), limit (default 12). |
| Relations | Eager load createdBy and assignedTo for the ticket list. |
supportTicketDetail(ticketId: ID!): TicketDetail!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Fetch ticket with all detail fields. Fetch ALL messages including internal notes (isInternalNote = true). Fetch audit log entries ordered by createdAt ASC. |
Key difference from myTicketDetail | Internal notes are INCLUDED. Audit log IS returned. |
| Relations | Eager load createdBy, assignedTo on ticket. Eager load sender, attachments on messages. Eager load actor on audit logs. |
supportDashboardStats: SupportDashboardStats!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Aggregate queries on SupportTicket table. |
| Fields | totalOpen, 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!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Same as supportDashboardStats plus agentPerformance array. |
| Agent performance | For 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!]!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Query all users who have the "support" role. For each, count assigned and resolved tickets. |
| Note | The "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!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Query TicketAuditLog with optional filters: ticketId, actorId, action, dateFrom/dateTo. Order by createdAt DESC. |
| Pagination | Offset-based: page (default 1), limit (default 30). |
| Relations | Eager load actor on each audit log entry. |
User-Facing Mutations
createSupportTicket(input: CreateSupportTicketInput!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user |
| Logic | 1. 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 log | Create TicketAuditLog entry: action = CREATED, actorId = currentUser.id, newValue = status ('OPEN'). |
| Notification | Publish supportTicketEvent subscription event with type = NEW_TICKET. Send notification to all support agents (see Notification Integration). |
| Validation | subject: required, max 500 chars. description: required, non-empty. category: required, must be valid enum. |
sendTicketMessage(input: SendTicketMessageInput!): TicketMessage!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user, must be the ticket creator |
| Logic | 1. 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 log | Create entry: action = MESSAGE_SENT. If status changed, create additional entry: action = STATUS_CHANGED (or REOPENED if was RESOLVED), previousValue = oldStatus, newValue = newStatus. |
| Notification | Publish 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. |
| Error | Throw FORBIDDEN if user is not the ticket creator. Throw BAD_REQUEST if ticket status is CLOSED (user must reopen first). |
closeMyTicket(ticketId: ID!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user, must be the ticket creator |
| Logic | Set status = CLOSED, closedAt = NOW(), lastActivityAt = NOW(). |
| Audit log | action = CLOSED, previousValue = oldStatus, newValue = 'CLOSED'. |
| Notification | Notify assigned agent (if any). Publish supportTicketEvent with type = STATUS_CHANGED. |
reopenMyTicket(ticketId: ID!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user, must be the ticket creator |
| Logic | Only allowed when status IN (RESOLVED, CLOSED). Set status = OPEN, clear resolvedAt and closedAt, set lastActivityAt = NOW(). |
| Audit log | action = REOPENED, previousValue = oldStatus, newValue = 'OPEN'. |
| Notification | Notify assigned agent (if any). Publish supportTicketEvent with type = STATUS_CHANGED. |
Support Portal Mutations
assignTicket(ticketId: ID!, agentId: ID!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | 1. 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 log | ASSIGNED or REASSIGNED with agent IDs. Plus STATUS_CHANGED if status transitioned. |
| Notification | Notify the newly assigned agent. If reassigned, also notify the previously assigned agent. |
updateTicketStatus(ticketId: ID!, status: TicketStatus!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Validate 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 log | action = STATUS_CHANGED (or RESOLVED/CLOSED/REOPENED as appropriate), previousValue = oldStatus, newValue = newStatus. |
| Notification | Notify ticket creator. Publish myTicketUpdated event. |
updateTicketPriority(ticketId: ID!, priority: TicketPriority!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Update priority, lastActivityAt = NOW(). |
| Audit log | action = PRIORITY_CHANGED, previousValue = oldPriority, newValue = newPriority. |
updateTicketCategory(ticketId: ID!, category: TicketCategory!): SupportTicket!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Update category, lastActivityAt = NOW(). |
| Audit log | action = CATEGORY_CHANGED, previousValue = oldCategory, newValue = newCategory. |
sendSupportReply(input: SendSupportReplyInput!): TicketMessage!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | 1. 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 data | Include 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 log | action = MESSAGE_SENT. If status changed, additional action = STATUS_CHANGED entry. |
| Notification | Notify ticket creator (the user). Publish myTicketUpdated event with the new message. |
addInternalNote(input: AddInternalNoteInput!): TicketMessage!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Create TicketMessage with senderRole = SUPPORT, isInternalNote = true, senderId = currentUser.id. Update lastActivityAt. Does NOT change ticket status. |
| Key behavior | Internal notes are visible ONLY via supportTicketDetail query. The myTicketDetail query filters them out. |
| Audit log | action = INTERNAL_NOTE_ADDED. |
| Notification | Notify other support agents assigned to or watching the ticket (NOT the ticket creator). |
Admin Mutations
adminAssignSupportRole(userId: ID!): MutationResponse!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Assign the "support" role to the specified user. This can leverage the existing RBAC system. See Support Role section for implementation options. |
| Validation | Verify user exists. Verify user does not already have the "support" role. |
| Response | { success: true, message: "Support role assigned successfully" } |
adminRevokeSupportRole(userId: ID!): MutationResponse!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Remove the "support" role from the specified user. Optionally: unassign the agent from any currently assigned tickets (or reassign to null). |
| Validation | Verify user exists. Verify user currently has the "support" role. |
| Response | { success: true, message: "Support role revoked successfully" } |
FAQ Mutations
createFaqEntry(input: CreateFaqInput!): FaqEntry!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Create FaqEntry. If status = PUBLISHED, set publishedAt = NOW(). |
| Validation | question: required, non-empty. answer: required, non-empty. category: required, valid enum. |
updateFaqEntry(id: ID!, input: UpdateFaqInput!): FaqEntry!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Update specified fields. If status changes to PUBLISHED and publishedAt IS NULL, set publishedAt = NOW(). |
deleteFaqEntry(id: ID!): MutationResponse!
| Aspect | Detail |
|---|---|
| Auth | Super admin only |
| Logic | Hard delete the FAQ entry and associated votes. |
markFaqHelpful(id: ID!): FaqHelpfulResponse!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user |
| Logic | 1. 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!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user (any role) |
| Logic | Query 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. |
| Pagination | Offset-based: page (default 1), limit (default 12). |
supportFaqs(input: SupportFaqsInput): PaginatedFaqs!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Query FaqEntry with all statuses visible. Optional filters: status, category, search. Order by updatedAt DESC. |
| Pagination | Offset-based: page (default 1), limit (default 12). |
Subscriptions
myTicketUpdated(ticketId: ID): TicketUpdateEvent!
| Aspect | Detail |
|---|---|
| Auth | Authenticated user (only receives events for their own tickets) |
| Logic | If ticketId is provided, filter to that specific ticket. Otherwise, emit events for all tickets created by the authenticated user. |
| Events emitted | When a support reply is sent to one of the user's tickets. When ticket status changes. When ticket is assigned/reassigned. |
| Payload | ticket: updated ticket data. newMessage: the new message (if event was triggered by a message). type: event type enum. |
| Filtering | Server-side: only emit to the connection of the user who created the ticket. Never emit internal notes through this subscription. |
supportTicketEvent: SupportTicketEventPayload!
| Aspect | Detail |
|---|---|
| Auth | Support agent OR super admin |
| Logic | Emits events for ALL tickets (not filtered to a specific user's tickets). |
| Events emitted | New ticket created. New user message on any ticket. Status changes. Priority changes. |
| Payload | ticket: 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, notUserCompanyRole. SupportRoleServiceupserts the support role at backend boot.SupportAgentGuardallows users assigned the global support role, and also allows platform super admins.User.isSupportAgentis 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
| From | To | Triggered By | Notes |
|---|---|---|---|
| (none) | OPEN | User creates ticket | Initial state |
OPEN | IN_PROGRESS | Agent assigns/picks up ticket | Automatic on assignment |
IN_PROGRESS | WAITING_ON_CUSTOMER | Agent sends reply | Automatic on support reply |
WAITING_ON_CUSTOMER | IN_PROGRESS | Customer sends message | Automatic on customer reply |
IN_PROGRESS | RESOLVED | Agent resolves | Manual action |
WAITING_ON_CUSTOMER | RESOLVED | Agent resolves | Manual action |
RESOLVED | OPEN | Customer replies | Automatic reopen |
RESOLVED | CLOSED | Auto-close after 7 days | Scheduled job |
RESOLVED | CLOSED | User or agent closes | Manual action |
OPEN | CLOSED | User or agent closes | Manual action |
IN_PROGRESS | CLOSED | Agent closes | Manual action |
WAITING_ON_CUSTOMER | CLOSED | Agent closes | Manual action |
CLOSED | OPEN | User reopens | Via reopenMyTicket mutation |
Automatic Status Changes
These status changes happen automatically as side effects of other actions:
- Agent assigns ticket: If
status = OPEN, transition toIN_PROGRESS - Agent sends reply (
sendSupportReply): Transition toWAITING_ON_CUSTOMER - Customer sends message (
sendTicketMessage): Ifstatus = WAITING_ON_CUSTOMER, transition toIN_PROGRESS. Ifstatus = RESOLVED, transition toOPEN(reopen) - 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
| Role | How Identified |
|---|---|
| User | Any authenticated user (has valid JWT) |
| Support Agent | User with isSupportAgent = true (has "support" role via UserRole table) |
| Super Admin | User with isSuperAdmin = true (existing mechanism) |
Query Authorization
| Query | User | Support Agent | Super Admin |
|---|---|---|---|
myTickets | Own tickets only | Own tickets only | Own tickets only |
myTicketDetail | Own tickets only | Own tickets only | Own tickets only |
myUnreadTicketCount | Yes | Yes | Yes |
supportTickets | DENIED | All tickets | All tickets |
supportTicketDetail | DENIED | All tickets (with internal notes + audit log) | All tickets (with internal notes + audit log) |
supportDashboardStats | DENIED | Yes | Yes |
adminSupportOverview | DENIED | DENIED | Yes |
adminListSupportAgents | DENIED | DENIED | Yes |
adminSupportAuditLog | DENIED | DENIED | Yes |
publishedFaqs | Yes | Yes | Yes |
supportFaqs | DENIED | Yes | Yes |
Mutation Authorization
| Mutation | User | Support Agent | Super Admin |
|---|---|---|---|
createSupportTicket | Yes | Yes | Yes |
sendTicketMessage | Own tickets only | Own tickets only (as user) | Own tickets only (as user) |
closeMyTicket | Own tickets only | Own tickets only | Own tickets only |
reopenMyTicket | Own tickets only | Own tickets only | Own tickets only |
assignTicket | DENIED | Yes | Yes |
updateTicketStatus | DENIED | Yes | Yes |
updateTicketPriority | DENIED | Yes | Yes |
updateTicketCategory | DENIED | Yes | Yes |
sendSupportReply | DENIED | Yes | Yes |
addInternalNote | DENIED | Yes | Yes |
adminAssignSupportRole | DENIED | DENIED | Yes |
adminRevokeSupportRole | DENIED | DENIED | Yes |
createFaqEntry | DENIED | Yes | Yes |
updateFaqEntry | DENIED | Yes | Yes |
deleteFaqEntry | DENIED | DENIED | Yes |
markFaqHelpful | Yes | Yes | Yes |
Subscription Authorization
| Subscription | User | Support Agent | Super Admin |
|---|---|---|---|
myTicketUpdated | Own tickets only | Own tickets only | Own tickets only |
supportTicketEvent | DENIED | Yes | Yes |
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
| Event | Who Gets Notified | Channel |
|---|---|---|
| New ticket created | All support agents | In-app notification + subscription event |
| User sends message | Assigned agent (or all agents if unassigned) | In-app notification + subscription event |
| Support reply sent | Ticket creator (user) | In-app notification + subscription event + email (optional) |
| Ticket assigned | Newly assigned agent | In-app notification |
| Ticket reassigned | New agent + old agent | In-app notification |
| Status changed to RESOLVED | Ticket creator | In-app notification + subscription event |
| Status changed to CLOSED | Ticket creator | In-app notification |
| Ticket reopened | Assigned agent (if any) | In-app notification |
| Internal note added | Other support agents on the ticket | In-app notification (support portal only) |
| Priority changed to URGENT | All support agents | In-app notification |
| Auto-closed (7 days) | Ticket creator | In-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
| Endpoint | Default Sort | Default Order |
|---|---|---|
myTickets | lastActivityAt | DESC |
supportTickets | lastActivityAt | DESC |
adminSupportAuditLog | createdAt | DESC |
publishedFaqs | helpfulCount then publishedAt | DESC |
supportFaqs | updatedAt | DESC |
| Ticket messages | createdAt | ASC (chronological) |
| Audit log entries | createdAt | ASC (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
| Code | When |
|---|---|
UNAUTHENTICATED | No valid JWT token |
FORBIDDEN | User lacks required role or is not the ticket owner |
NOT_FOUND | Ticket, message, or FAQ entry does not exist |
BAD_REQUEST | Invalid input (e.g., invalid status transition, sending message to CLOSED ticket) |
CONFLICT | Duplicate operation (e.g., assigning a role the user already has) |
Rate Limiting Recommendations
| Operation | Suggested Limit |
|---|---|
createSupportTicket | 5 per hour per user |
sendTicketMessage | 20 per hour per user |
markFaqHelpful | 30 per hour per user |
publishedFaqs | 60 per minute per user |
File Upload (Attachments)
Ticket message attachments follow the existing S3 upload pattern used elsewhere in the application:
- Client uploads file via
apollo-upload-client(multipart/form-data) - Server receives file in resolver, uploads to S3 at path:
support/tickets/{ticketId}/{messageId}/{filename} - Store
objectPathinTicketAttachmenttable - 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
/supportand 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:
myTicketDetailresolver: MUST filter out messages whereisInternalNote = truesupportTicketDetailresolver: MUST include messages whereisInternalNote = 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.
| Fragment | Expected GraphQL Type | Key Fields |
|---|---|---|
TicketUserFields | TicketUser | id, firstName, lastName, email, profileImageUrl |
SupportTicketFields | SupportTicketOutput | id, ticketNumber, subject, description, category, priority, status, createdById, createdBy { ...TicketUserFields }, assignedToId, assignedTo { ...TicketUserFields }, companyId, lastActivityAt, firstResponseAt, resolvedAt, closedAt, createdAt, updatedAt |
TicketAttachmentFields | TicketAttachmentOutput | id, messageId, filename, contentType, sizeBytes, objectPath, downloadUrl, createdAt |
TicketMessageFields | TicketMessageOutput | id, ticketId, senderId, sender { ...TicketUserFields }, senderRole, content, isInternalNote, attachments { ...TicketAttachmentFields }, createdAt, updatedAt |
TicketAuditLogFields | TicketAuditLogOutput | id, ticketId, actorId, actor { ...TicketUserFields }, action, previousValue, newValue, metadata, createdAt |
FaqEntryFields | FaqEntryOutput | id, 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:
| Mutation | Audit Action | previousValue | newValue | metadata |
|---|---|---|---|---|
createSupportTicket | CREATED | null | "OPEN" | { category, priority } |
sendTicketMessage | MESSAGE_SENT | null | null | { messageId } |
sendTicketMessage (status change) | STATUS_CHANGED or REOPENED | old status | new status | { trigger: "customer_reply" } |
closeMyTicket | CLOSED | old status | "CLOSED" | null |
reopenMyTicket | REOPENED | old status | "OPEN" | null |
assignTicket (first) | ASSIGNED | null | agentId | { agentName } |
assignTicket (reassign) | REASSIGNED | old agentId | new agentId | { oldAgentName, newAgentName } |
updateTicketStatus | STATUS_CHANGED | old status | new status | null |
updateTicketStatus (RESOLVED) | RESOLVED | old status | "RESOLVED" | null |
updateTicketStatus (CLOSED) | CLOSED | old status | "CLOSED" | null |
updateTicketPriority | PRIORITY_CHANGED | old priority | new priority | null |
updateTicketCategory | CATEGORY_CHANGED | old category | new category | null |
sendSupportReply | MESSAGE_SENT | null | null | { messageId } |
sendSupportReply (status change) | STATUS_CHANGED | old status | "WAITING_ON_CUSTOMER" | { trigger: "support_reply" } |
addInternalNote | INTERNAL_NOTE_ADDED | null | null | { messageId } |
| Auto-close (cron) | CLOSED | "RESOLVED" | "CLOSED" | { reason: "auto_close_7_days" } |