İçeriğe geç

Support Ticket System — Design Specification

Derin

Date: 2026-04-01 Status: Approved Approach: Monolithic — fully integrated into existing Next.js application


A ticket-based support system between customers and admins within the ECU Tuning Portal. Both customers and admins can create tickets. The system includes admin-managed categories, priority levels, file attachments, email + in-app notifications, and optional relation to existing resources (File, Invoice, DevelopmentRequest).

AreaDecision
TypeTicket-based support (Zendesk-style)
Who can createBoth customers and admins
CategoriesAdmin-managed CRUD (multilingual JSON names)
PriorityLow / Normal / High / Urgent
File attachmentsBoth customer and admin can attach files to messages
NotificationsEmail + in-app (bell icon + badge)
Resource linkingOptional: File, Invoice, DevelopmentRequest
AssignmentNo assignment — all admins see all tickets

Admin-managed category table with multilingual names.

model TicketCategory {
id String @id @default(cuid())
name Json // {"tr": "Teknik Destek", "en": "Technical Support", ...}
slug String @unique
color String? // Badge color hex: "#ef4444"
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tickets Ticket[]
}

Main ticket record with status workflow, priority, and optional resource relations.

model Ticket {
id String @id @default(cuid())
ticketNumber String @unique // Auto-generated: "TKT-00001"
subject String
status String @default("open") // open, awaiting_customer, awaiting_admin, resolved, closed
priority String @default("normal") // low, normal, high, urgent
customerId String
createdById String // ID of the person who created the ticket
createdByType String // "customer" | "admin"
categoryId String?
relatedFileId String?
relatedInvoiceId String?
relatedDevRequestId String?
lastMessageAt DateTime @default(now())
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages TicketMessage[]
notifications TicketNotification[]
customer Customer @relation(fields: [customerId], references: [id])
category TicketCategory? @relation(fields: [categoryId], references: [id])
relatedFile File? @relation(fields: [relatedFileId], references: [id])
relatedInvoice Invoice? @relation(fields: [relatedInvoiceId], references: [id])
relatedDevRequest DevelopmentRequest? @relation(fields: [relatedDevRequestId], references: [id])
}

Status flow:

open --> awaiting_customer (admin replied)
--> awaiting_admin (customer replied)
--> resolved (marked as resolved by either side)
--> closed (after resolved, or direct close)

Individual messages within a ticket thread.

model TicketMessage {
id String @id @default(cuid())
ticketId String
content String
senderType String // "customer" | "admin"
senderId String // Customer.id or AdminUser.id
isInternal Boolean @default(false) // Admin-only internal note
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ticket Ticket @relation(fields: [ticketId], references: [id])
attachments TicketAttachment[]
}

Files attached to messages.

model TicketAttachment {
id String @id @default(cuid())
messageId String
fileName String // Original file name
filePath String // Server storage path
fileSize Int // Bytes
mimeType String
createdAt DateTime @default(now())
message TicketMessage @relation(fields: [messageId], references: [id])
}

In-app notification records.

model TicketNotification {
id String @id @default(cuid())
ticketId String
recipientType String // "customer" | "admin"
recipientId String
type String // "new_ticket" | "new_message" | "status_changed" | "ticket_closed"
title String
body String
isRead Boolean @default(false)
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
}
GET /api/admin/tickets → List all tickets (filter: status, priority, category, customer, search)
POST /api/admin/tickets → Create ticket on behalf of customer
GET /api/admin/tickets/[id] → Ticket detail + messages
PATCH /api/admin/tickets/[id] → Update ticket (status, priority, category)
POST /api/admin/tickets/[id]/messages → Send message (isInternal optional)
POST /api/admin/tickets/[id]/close → Close ticket
GET /api/admin/tickets/stats → Dashboard stats (open, pending, resolved counts)
GET /api/admin/ticket-categories → List all categories
POST /api/admin/ticket-categories → Create category
PATCH /api/admin/ticket-categories/[id] → Update category
DELETE /api/admin/ticket-categories/[id] → Soft delete (isActive=false)
GET /api/admin/notifications → Admin notifications (with unread count)
PATCH /api/admin/notifications/[id]/read → Mark single as read
PATCH /api/admin/notifications/read-all → Mark all as read
GET /api/customer/tickets → Customer's own tickets
POST /api/customer/tickets → Create new ticket
GET /api/customer/tickets/[id] → Ticket detail + messages (isInternal=false only)
POST /api/customer/tickets/[id]/messages → Send message
POST /api/customer/tickets/[id]/close → Close own ticket
GET /api/customer/notifications → Customer notifications
PATCH /api/customer/notifications/[id]/read → Mark single as read
PATCH /api/customer/notifications/read-all → Mark all as read
POST /api/upload/ticket-attachment → Upload file (multipart/form-data)
GET /api/admin/tickets/[id]/attachments/[attachmentId] → Download (with auth check)
GET /api/customer/tickets/[id]/attachments/[attachmentId] → Download (with auth + ownership check)
/[locale]/admin/(panel)/tickets/page.tsx → Ticket list (table with filters, search, sorting)
/[locale]/admin/(panel)/tickets/[id]/page.tsx → Ticket detail (message thread + status controls)
/[locale]/admin/(panel)/ticket-categories/page.tsx → Category CRUD (inline table or modal)
/[locale]/admin/(panel)/dashboard/page.tsx → (existing) + Ticket summary widget added
/[locale]/customer/(portal)/tickets/page.tsx → Customer's ticket list
/[locale]/customer/(portal)/tickets/[id]/page.tsx → Ticket detail (message thread)
/[locale]/customer/(portal)/tickets/new/page.tsx → New ticket form
/[locale]/customer/(portal)/dashboard/page.tsx → (existing) + Ticket summary widget added
/components/tickets/
├── TicketList.tsx → Table: ticketNumber, subject, status badge, priority badge, category, date
├── TicketDetail.tsx → Message thread + reply form + status controls
├── TicketMessageBubble.tsx → Single message bubble (admin/customer different colors)
├── TicketForm.tsx → New ticket form (subject, category, priority, related resource, message, file)
├── TicketStatusBadge.tsx → Color-coded status badge
├── TicketPriorityBadge.tsx → Color-coded priority badge
├── TicketFilters.tsx → Admin filter bar (status, priority, category, search)
├── TicketAttachmentUpload.tsx → File upload dropzone
├── TicketRelationSelect.tsx → Related File/Invoice/DevRequest picker (search + dropdown)
└── InternalNoteToggle.tsx → Admin: "Internal note" toggle switch
/components/notifications/
├── NotificationBell.tsx → Header bell icon + unread count badge
├── NotificationDropdown.tsx → Dropdown notification list on click
└── NotificationItem.tsx → Single notification row (icon, title, time, read/unread)

Following existing admin panel glass morphism + dark theme:

  • Message bubbles: Admin messages left-aligned (brand-panel background), customer messages right-aligned (brand-red tint)
  • Internal notes: Amber/yellow border, “Internal Note” label — visible only to admins
  • Status badges: open → blue, awaiting_customer → orange, awaiting_admin → purple, resolved → green, closed → gray
  • Priority badges: low → gray, normal → blue, high → orange, urgent → red with pulse animation
  • Notification bell: Right side of header, red badge + pulse animation when unread exists
  • NotificationBell component polls GET /api/[role]/notifications?unreadOnly=true every 30 seconds
  • First load fetches immediately, then interval starts
  • Click on bell opens dropdown with last 20 notifications
  • Click on notification navigates to the related ticket + marks as read
EventNotifiedType
Customer creates ticketAll adminsnew_ticket
Admin creates ticket (on behalf)Related customernew_ticket
Customer sends messageAll adminsnew_message
Admin sends message (not internal)Related customernew_message
Ticket status changesRelated customerstatus_changed
Ticket closedOther partyticket_closed

Internal notes (isInternal=true) NEVER trigger customer notifications.

New email templates using existing @react-email/components + base-layout.tsx:

/lib/mail/templates/
├── ticket-new.tsx → "Your support ticket has been created: TKT-00042"
├── ticket-reply.tsx → "New reply on your support ticket: TKT-00042"
├── ticket-status-changed.tsx → "Your ticket status has been updated: TKT-00042"
└── ticket-closed.tsx → "Support ticket closed: TKT-00042"

Each template:

  • Uses existing base-layout.tsx (co-branded header/footer)
  • Translated to customer’s locale via next-intl
  • Includes ticket number, subject, message preview, and “View Ticket” CTA button
  • Applies existing anti-spam headers
/lib/ticket-notifications.ts
├── createTicketNotification() → Writes TicketNotification to DB + sends email
├── notifyNewTicket() → New ticket notification
├── notifyNewMessage() → New message notification
├── notifyStatusChange() → Status change notification
└── notifyTicketClosed() → Closure notification
  • Admin routes protected by existing requireAdmin() middleware
  • Customer routes protected by existing validateCustomerSession()
  • Customer can only access own tickets — customerId filter enforced on every query
  • isInternal=true messages filtered out at query level for customer API
  • Max file size: 10MB
  • Allowed MIME types: image/*, application/pdf, .bin, .zip, .rar, .7z
  • File names sanitized (special characters removed)
  • Files stored in /uploads/tickets/{ticketId}/ — not publicly accessible
  • File downloads served through API with auth check
  • Ticket creation: max 10 per customer per hour
  • Message sending: max 5 per person per minute
  • Uses existing in-memory rate limiter pattern
  • subject: max 200 characters
  • content (message body): max 5000 characters
  • XSS protection: React escapes content by default on render

Uses existing next-intl infrastructure across all 24 supported locales.

Translation keys added to /messages/{locale}.json:

{
"tickets": {
"title": "Support Tickets",
"newTicket": "New Ticket",
"subject": "Subject",
"status": {
"open": "Open",
"awaiting_customer": "Awaiting Your Reply",
"awaiting_admin": "Awaiting Support Reply",
"resolved": "Resolved",
"closed": "Closed"
},
"priority": {
"low": "Low",
"normal": "Normal",
"high": "High",
"urgent": "Urgent"
},
"messages": {
"reply": "Reply",
"internalNote": "Internal Note",
"sendMessage": "Send Message",
"attachFile": "Attach File"
},
"notifications": {
"newTicket": "New support ticket",
"newMessage": "New reply on ticket",
"statusChanged": "Ticket status updated",
"ticketClosed": "Ticket closed"
}
}
}
  • Category names stored as JSON with per-locale translations
  • Email templates rendered in customer’s locale
  • Admin panel uses admin’s browser locale
/uploads/
└── tickets/
└── {ticketId}/
└── {messageId}/
├── screenshot.png
├── ecufile.bin
└── ...

Auto-incrementing format: TKT-00001, TKT-00002, etc.

Implementation: Query the latest ticket’s ticketNumber, parse the numeric part, increment by 1, and pad to 5 digits. Use a database transaction to prevent race conditions.