Skip to content

Epic 28 — Reports & Help (Support Tickets)

Users can open a support/help request from anywhere in the platform — optionally linking it to a job, post, application, or user. Requests appear in the admin panel as support tickets. Admins reply inside the thread, change status, and can assign tickets to themselves. Users are notified on every admin reply. The ticket flows through New → Open → Waiting Response → Solved / Closed (Solved and Closed are terminal).


BE-SUPPORT-001 — SupportTicket + SupportTicketReply models

  • [x] Done

Files:

  • Edit: prisma/schema.prisma — add SupportTicket, SupportTicketReply models + SupportTicketStatus, SupportTicketCategory, SupportLinkedEntityType enums
  • Run: pnpm db:migrate

Schema:

prisma
enum SupportTicketStatus {
  NEW
  OPEN
  WAITING_RESPONSE
  SOLVED
  CLOSED
}

enum SupportTicketCategory {
  GENERAL
  TECHNICAL
  JOB
  APPLICATION
  PAYMENT
  ACCOUNT
  CONTENT
  OTHER
}

enum SupportLinkedEntityType {
  JOB
  POST
  APPLICATION
  USER
  PET_JOB
}

model SupportTicket {
  id               String                   @id @default(cuid())
  userId           String
  user             User                     @relation("SupportTicketUser", fields: [userId], references: [id], onDelete: Cascade)
  subject          String
  body             String
  category         SupportTicketCategory    @default(GENERAL)
  status           SupportTicketStatus      @default(NEW)
  linkedEntityType SupportLinkedEntityType?
  linkedEntityId   String?
  assignedAdminId  String?
  assignedAdmin    User?                    @relation("SupportTicketAdmin", fields: [assignedAdminId], references: [id])
  replies          SupportTicketReply[]
  createdAt        DateTime                 @default(now())
  updatedAt        DateTime                 @updatedAt

  @@index([userId, createdAt])
  @@index([status, createdAt])
  @@index([assignedAdminId])
  @@map("support_tickets")
}

model SupportTicketReply {
  id           String        @id @default(cuid())
  ticketId     String
  ticket       SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
  authorId     String
  author       User          @relation("SupportTicketReplyAuthor", fields: [authorId], references: [id])
  body         String
  isAdminReply Boolean       @default(false)
  createdAt    DateTime      @default(now())

  @@index([ticketId, createdAt])
  @@map("support_ticket_replies")
}

User model gets 3 new relations:

prisma
supportTickets          SupportTicket[]         @relation("SupportTicketUser")
assignedSupportTickets  SupportTicket[]         @relation("SupportTicketAdmin")
supportTicketReplies    SupportTicketReply[]    @relation("SupportTicketReplyAuthor")

BE-SUPPORT-002 — Support ticket service + GraphQL

  • [x] Done

Files:

  • Create: src/services/support/index.ts
  • Edit: src/graphql/schema/index.ts — types + queries + mutations
  • Edit: src/graphql/resolvers/admin.ts — admin mutations
  • Create: src/graphql/resolvers/support.ts
  • Edit: src/graphql/resolvers/index.ts
  • Create: src/__tests__/services/support.test.ts
  • Create: src/__tests__/resolvers/support.test.ts

GraphQL:

graphql
enum SupportTicketStatus { NEW OPEN WAITING_RESPONSE SOLVED CLOSED }
enum SupportTicketCategory { GENERAL TECHNICAL JOB APPLICATION PAYMENT ACCOUNT CONTENT OTHER }
enum SupportLinkedEntityType { JOB POST APPLICATION USER PET_JOB }

type SupportTicket {
  id: ID!
  userId: ID!
  user: User!
  subject: String!
  body: String!
  category: SupportTicketCategory!
  status: SupportTicketStatus!
  linkedEntityType: SupportLinkedEntityType
  linkedEntityId: ID
  assignedAdmin: User
  replies: [SupportTicketReply!]!
  replyCount: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}
type SupportTicketReply {
  id: ID!
  ticketId: ID!
  authorId: ID!
  author: User!
  body: String!
  isAdminReply: Boolean!
  createdAt: DateTime!
}
type SupportTicketPage {
  tickets: [SupportTicket!]!
  page: Int!; pageSize: Int!; totalCount: Int!; totalPages: Int!
}
input CreateSupportTicketInput {
  subject: String!
  body: String!
  category: SupportTicketCategory
  linkedEntityType: SupportLinkedEntityType
  linkedEntityId: ID
}
input SupportTicketFilter { status: SupportTicketStatus; category: SupportTicketCategory; assignedAdminId: ID; userId: ID }

# Queries
mySupportTickets(page: Int, pageSize: Int, status: SupportTicketStatus): SupportTicketPage!
supportTicket(id: ID!): SupportTicket
adminSupportTickets(filter: SupportTicketFilter, page: Int, pageSize: Int): SupportTicketPage!   # ADMIN

# Mutations
createSupportTicket(input: CreateSupportTicketInput!): SupportTicket!
addSupportTicketReply(ticketId: ID!, body: String!): SupportTicketReply!
updateSupportTicketStatus(ticketId: ID!, status: SupportTicketStatus!): SupportTicket!  # ADMIN
assignSupportTicket(ticketId: ID!, adminId: ID): SupportTicket!                         # ADMIN (adminId null = unassign)

Business rules:

  • createSupportTicket: validates subject (1–200 chars), body (1–2000 chars). Auto-opens if user already has an open ticket in same category (no duplicate NEW tickets per category).
  • addSupportTicketReply: both user (ticket owner) and admin can reply. When user replies → status auto-transitions NEW/SOLVED → OPEN; CLOSED tickets cannot receive replies. When admin replies → status auto-transitions NEW/OPEN → WAITING_RESPONSE; fires SUPPORT_REPLY notification to ticket owner.
  • updateSupportTicketStatus: admin only; SOLVED and CLOSED are terminal (cannot re-open). CLOSED = admin manually closes. SOLVED = issue resolved, waiting user confirmation.
  • Linked entity IDs are stored as plain strings (no FK constraint). Validation: if linkedEntityType set, linkedEntityId must also be set.

Notifications: add SUPPORT_REPLY and SUPPORT_STATUS_CHANGED to the NotificationType enum.


FE-SUPPORT-001 — User-facing help/support pages

  • [x] Done

Files:

  • Create: apps/app/src/pages/support/SupportPage.tsx — list my tickets + create button
  • Create: apps/app/src/pages/support/SupportTicketDetailPage.tsx — thread view + reply
  • Create: apps/app/src/hooks/useSupportTickets.ts
  • Create: apps/app/src/lib/queries/support.ts
  • Edit: apps/app/src/App.tsx — add routes /help, /help/:id
  • Edit: apps/app/src/components/AppShell.tsx — add Help nav item (question mark icon)

Description:

  • /help — "My Support Tickets" list with status chips, empty state with "Open a ticket" CTA.
  • Create ticket form: subject, body (textarea), category select, optional entity link (type + ID fields).
  • /help/:id — conversation thread: original message + replies as a chat-style timeline. Admin replies visually distinct (CasTyou logo + "Support Team" name). Reply input at bottom (disabled if CLOSED). Status chip with timestamp.

FE-SUPPORT-002 — Admin support panel

  • [x] Done

Files:

  • Create: apps/app/src/pages/admin/AdminSupportPage.tsx — paginated ticket list with filters
  • Create: apps/app/src/pages/admin/AdminSupportTicketPage.tsx — ticket thread + admin controls
  • Create: apps/app/src/hooks/useAdminSupport.ts
  • Edit: apps/app/src/components/AdminShell.tsx — enable & wire "Support" nav item
  • Edit: apps/app/src/App.tsx — add routes

Description:

  • Admin ticket list: filter by status/category/assigned. Columns: status chip, subject, user, category, last activity, reply count, assigned admin.
  • Ticket detail: same thread view as user side plus admin-only controls: status dropdown, assign-to-me button, linked entity section (shows a pill linking to the related entity). Reply input always shown for admins.