H.2: Support Ticketing System
Overview
The BIO-QMS Support Ticketing System provides multi-channel customer support with automated routing, tier-based SLA management, and AI-powered self-service capabilities. Designed specifically for regulated life sciences environments, the system ensures compliance with quality system requirements while delivering rapid response to critical incidents.
Key Features
- Multi-Channel Intake: In-app widget, email-to-ticket, Slack integration, API access
- AI-Powered Routing: Automatic categorization and intelligent assignment
- Tier-Based SLAs: Response time guarantees from 15 minutes (Enterprise Priority) to 24 hours (Starter)
- Agent Dashboard: Unified queue management with customer context and canned responses
- Self-Service Portal: AI-powered knowledge base with ticket deflection tracking
- Compliance Integration: Audit trail, CAPA linking, validation evidence capture
Architecture Components
┌─────────────────────────────────────────────────────────────────┐
│ Support Ticketing System │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Multi-Channel│ │ Routing │ │ SLA │ │
│ │ Intake │─▶│ & AI Auto- │─▶│ Management │ │
│ │ │ │ Categorization│ │ & Tracking │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Ticket Data Store (PostgreSQL) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Agent │ │ Knowledge │ │ Customer │ │
│ │ Dashboard │ │ Base │ │ Portal │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
H.2.1: Ticket Data Model
Database Schema
// Support Ticket Data Model
model SupportTicket {
id String @id @default(cuid())
ticketNumber String @unique // e.g., "TKT-2024-00042"
// Tenant & Reporter
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
reporterId String
reporter User @relation("TicketReporter", fields: [reporterId], references: [id])
reporterEmail String
reporterName String
reporterRole String // QA Manager, Regulatory Affairs, etc.
// Categorization
category TicketCategory
subcategory String?
affectedModule String? // WO, Document, Equipment, Supplier, Training, etc.
severity TicketSeverity
priority TicketPriority
// Status & Assignment
status TicketStatus @default(NEW)
assigneeId String?
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
assignedTeam SupportTeam?
escalationLevel Int @default(0) // 0=L1, 1=L2, 2=Engineering, 3=Leadership
// Content
subject String
description String @db.Text
stepsToReproduce String? @db.Text
expectedBehavior String? @db.Text
actualBehavior String? @db.Text
// Metadata
source TicketSource // IN_APP, EMAIL, SLACK, API
browserInfo Json?
systemVersion String?
attachments TicketAttachment[]
// SLA Tracking
slaDeadline DateTime?
slaStatus SLAStatus @default(ON_TRACK)
firstResponseAt DateTime?
firstResponseBy String? // User ID
resolvedAt DateTime?
resolvedBy String?
closedAt DateTime?
closedBy String?
// Time Tracking
totalResponseTime Int? // minutes
totalResolutionTime Int? // minutes
timeToFirstResponse Int? // minutes
businessHoursOnly Boolean @default(true)
// Customer Context
subscriptionTier String // Starter, Professional, Enterprise, Enterprise Priority
healthScore Float? // Customer health score at time of ticket
lastTicketDate DateTime?
totalTicketsCount Int @default(0)
// Compliance & Quality
complianceRelated Boolean @default(false)
capaId String?
capa CAPA? @relation(fields: [capaId], references: [id])
auditTrailId String?
validationImpact Boolean @default(false)
regulatoryRisk RegulatoryRiskLevel?
// Resolution
resolutionType ResolutionType?
resolutionNotes String? @db.Text
rootCause String? @db.Text
preventiveAction String? @db.Text
// Knowledge Base
knowledgeBaseArticleId String?
knowledgeArticle KnowledgeArticle? @relation(fields: [knowledgeBaseArticleId], references: [id])
deflected Boolean @default(false) // Was ticket avoided via KB?
// Related Records
comments TicketComment[]
statusHistory TicketStatusHistory[]
escalations TicketEscalation[]
internalNotes TicketInternalNote[]
// Satisfaction
satisfactionRating Int? // 1-5 stars
satisfactionComment String? @db.Text
satisfactionDate DateTime?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status, priority])
@@index([assigneeId, status])
@@index([category, status])
@@index([slaDeadline, slaStatus])
@@index([ticketNumber])
@@index([reporterId])
@@map("support_tickets")
}
enum TicketCategory {
BUG // Software defect
FEATURE_REQUEST // Enhancement or new capability
HOW_TO // Usage question
COMPLIANCE_QUESTION // Regulatory/compliance guidance
INTEGRATION_HELP // API, SSO, external system integration
PERFORMANCE // System speed/responsiveness
DATA_ISSUE // Data integrity, migration, export
ACCESS_ISSUE // Login, permissions, user management
CONFIGURATION // System setup, customization
TRAINING // Educational resources, onboarding
DOCUMENTATION // User guide, release notes
VALIDATION // IQ/OQ/PQ support
AUDIT_SUPPORT // Audit trail, evidence generation
OTHER // Miscellaneous
}
enum TicketSeverity {
CRITICAL // System down, data loss, compliance violation
HIGH // Major function unavailable, workaround exists
MEDIUM // Minor function impaired, minimal impact
LOW // Cosmetic issue, enhancement
}
enum TicketPriority {
P0 // Immediate - Critical production issue
P1 // Urgent - High-impact issue
P2 // Normal - Standard request
P3 // Low - Enhancement, cosmetic
}
enum TicketStatus {
NEW // Just created, not yet triaged
TRIAGED // Categorized, assigned
IN_PROGRESS // Actively being worked
WAITING_CUSTOMER // Awaiting customer response
WAITING_INTERNAL // Awaiting internal resource
ESCALATED // Escalated to higher tier
RESOLVED // Solution provided
CLOSED // Confirmed resolved by customer
CANCELLED // Closed without resolution
}
enum TicketSource {
IN_APP // Widget in BIO-QMS UI
EMAIL // Email-to-ticket
SLACK // Slack integration
API // Programmatic creation
PHONE // Manual entry from phone call
PORTAL // Customer self-service portal
}
enum SLAStatus {
ON_TRACK // Within SLA window
AT_RISK // 75%+ of SLA consumed
BREACHED // SLA deadline passed
PAUSED // SLA timer paused (waiting on customer)
RESOLVED // Resolved within SLA
BREACHED_RESOLVED // Resolved after SLA breach
}
enum SupportTeam {
L1_SUPPORT // Tier 1 - General inquiries
L2_SUPPORT // Tier 2 - Technical specialists
ENGINEERING // Product engineering team
COMPLIANCE // Regulatory/compliance experts
DEVOPS // Infrastructure/operations
CUSTOMER_SUCCESS // CSMs for strategic accounts
}
enum ResolutionType {
SOLVED // Issue resolved
WORKAROUND // Temporary solution provided
DUPLICATE // Duplicate of existing ticket
WONT_FIX // Not a bug / working as designed
ENHANCEMENT // Feature request logged
DOCUMENTATION // Directed to existing docs
TRAINING // Training provided
EXTERNAL // Third-party issue
}
enum RegulatoryRiskLevel {
NONE
LOW
MEDIUM
HIGH
CRITICAL
}
// Ticket Attachments
model TicketAttachment {
id String @id @default(cuid())
ticketId String
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
fileName String
fileSize Int
mimeType String
storageKey String // S3/GCS key
uploadedById String
uploadedBy User @relation(fields: [uploadedById], references: [id])
isScreenshot Boolean @default(false)
isLog Boolean @default(false)
scanStatus String? // Virus scan result
createdAt DateTime @default(now())
@@index([ticketId])
@@map("ticket_attachments")
}
// Ticket Comments (Customer-Visible)
model TicketComment {
id String @id @default(cuid())
ticketId String
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id])
authorType String // CUSTOMER, SUPPORT_AGENT, SYSTEM
content String @db.Text
isInternal Boolean @default(false)
attachments Json? // Array of attachment IDs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ticketId, createdAt])
@@map("ticket_comments")
}
// Internal Notes (Not Customer-Visible)
model TicketInternalNote {
id String @id @default(cuid())
ticketId String
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id])
content String @db.Text
noteType String? // TROUBLESHOOTING, ESCALATION, RESEARCH
createdAt DateTime @default(now())
@@index([ticketId])
@@map("ticket_internal_notes")
}
// Status Change History
model TicketStatusHistory {
id String @id @default(cuid())
ticketId String
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
fromStatus TicketStatus?
toStatus TicketStatus
changedById String
changedBy User @relation(fields: [changedById], references: [id])
reason String? @db.Text
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("ticket_status_history")
}
// Escalation Log
model TicketEscalation {
id String @id @default(cuid())
ticketId String
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
fromTeam SupportTeam?
toTeam SupportTeam
fromAssigneeId String?
toAssigneeId String?
reason String // SLA_BREACH, COMPLEXITY, CUSTOMER_REQUEST
notes String? @db.Text
escalatedById String
escalatedBy User @relation(fields: [escalatedById], references: [id])
createdAt DateTime @default(now())
@@index([ticketId])
@@map("ticket_escalations")
}
// SLA Configuration by Subscription Tier
model SLAConfiguration {
id String @id @default(cuid())
subscriptionTier String @unique
// Response Time SLAs (minutes)
p0ResponseTime Int // Critical
p1ResponseTime Int // Urgent
p2ResponseTime Int // Normal
p3ResponseTime Int // Low
// Resolution Time SLAs (minutes)
p0ResolutionTime Int
p1ResolutionTime Int
p2ResolutionTime Int
p3ResolutionTime Int
// Escalation Thresholds (% of SLA consumed)
l1EscalationThreshold Float @default(0.75)
l2EscalationThreshold Float @default(0.50)
// Business Hours
businessHoursOnly Boolean @default(true)
businessHoursStart String // "09:00"
businessHoursEnd String // "17:00"
businessDays Json // ["MON", "TUE", "WED", "THU", "FRI"]
timezone String @default("America/New_York")
// Support Channels Enabled
slackEnabled Boolean @default(false)
phoneEnabled Boolean @default(false)
priorityQueueAccess Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sla_configurations")
}
SLA Tier Configuration
| Tier | P0 Response | P1 Response | P2 Response | P3 Response | Channels |
|---|---|---|---|---|---|
| Starter | 4 hours | 8 hours | 24 hours | 72 hours | In-app, Email |
| Professional | 2 hours | 4 hours | 8 hours | 24 hours | In-app, Email |
| Enterprise | 1 hour | 2 hours | 4 hours | 8 hours | In-app, Email, Slack |
| Enterprise Priority | 15 minutes | 30 minutes | 2 hours | 4 hours | In-app, Email, Slack, Phone |
Data Validation Rules
// Ticket Creation Validation
const TICKET_VALIDATION_RULES = {
subject: {
minLength: 10,
maxLength: 200,
required: true
},
description: {
minLength: 20,
maxLength: 10000,
required: true
},
category: {
required: true,
enum: Object.values(TicketCategory)
},
priority: {
required: true,
enum: Object.values(TicketPriority),
defaultForSeverity: {
CRITICAL: 'P0',
HIGH: 'P1',
MEDIUM: 'P2',
LOW: 'P3'
}
},
attachments: {
maxCount: 10,
maxFileSize: 25 * 1024 * 1024, // 25 MB
allowedMimeTypes: [
'image/png',
'image/jpeg',
'application/pdf',
'text/plain',
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/json',
'text/xml'
]
}
};
// Category-Specific Required Fields
const CATEGORY_REQUIREMENTS: Record<TicketCategory, string[]> = {
BUG: ['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'browserInfo'],
FEATURE_REQUEST: ['description', 'expectedBehavior'],
HOW_TO: ['description', 'affectedModule'],
COMPLIANCE_QUESTION: ['description', 'affectedModule'],
INTEGRATION_HELP: ['description', 'affectedModule'],
PERFORMANCE: ['stepsToReproduce', 'browserInfo'],
DATA_ISSUE: ['description', 'affectedModule'],
ACCESS_ISSUE: ['description', 'reporterEmail'],
CONFIGURATION: ['description', 'affectedModule'],
TRAINING: ['description'],
DOCUMENTATION: ['description'],
VALIDATION: ['description', 'affectedModule'],
AUDIT_SUPPORT: ['description'],
OTHER: ['description']
};
Ticket Number Generation
// Sequential Ticket Number Generation
export async function generateTicketNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `TKT-${year}`;
// Get last ticket number for this year and tenant
const lastTicket = await prisma.supportTicket.findFirst({
where: {
tenantId,
ticketNumber: {
startsWith: prefix
}
},
orderBy: {
createdAt: 'desc'
},
select: {
ticketNumber: true
}
});
let sequence = 1;
if (lastTicket) {
const match = lastTicket.ticketNumber.match(/-(\d+)$/);
if (match) {
sequence = parseInt(match[1], 10) + 1;
}
}
return `${prefix}-${sequence.toString().padStart(5, '0')}`;
}
// Example: TKT-2026-00042
H.2.2: Multi-Channel Intake
In-App Widget
// React Component: Support Widget
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { FileUpload } from '@/components/ui/file-upload';
import { HelpCircle, MessageSquare, AlertCircle } from 'lucide-react';
interface SupportWidgetProps {
tenantId: string;
userId: string;
currentModule?: string;
}
export function SupportWidget({ tenantId, userId, currentModule }: SupportWidgetProps) {
const [open, setOpen] = useState(false);
const [category, setCategory] = useState<string>('');
const [priority, setPriority] = useState<string>('P2');
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [attachments, setAttachments] = useState<File[]>([]);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
// Collect browser info
const browserInfo = {
userAgent: navigator.userAgent,
screenResolution: `${window.screen.width}x${window.screen.height}`,
viewport: `${window.innerWidth}x${window.innerHeight}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language
};
// Create FormData for file uploads
const formData = new FormData();
formData.append('tenantId', tenantId);
formData.append('reporterId', userId);
formData.append('category', category);
formData.append('priority', priority);
formData.append('subject', subject);
formData.append('description', description);
formData.append('source', 'IN_APP');
formData.append('affectedModule', currentModule || '');
formData.append('browserInfo', JSON.stringify(browserInfo));
formData.append('systemVersion', process.env.NEXT_PUBLIC_APP_VERSION || '');
attachments.forEach((file) => {
formData.append('attachments', file);
});
const response = await fetch('/api/support/tickets', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Failed to create ticket');
}
const ticket = await response.json();
// Show success message
alert(`Ticket ${ticket.ticketNumber} created successfully!`);
// Reset form
setOpen(false);
setCategory('');
setSubject('');
setDescription('');
setAttachments([]);
} catch (error) {
console.error('Failed to submit ticket:', error);
alert('Failed to create ticket. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<>
{/* Floating Help Button */}
<Button
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 rounded-full w-14 h-14 shadow-lg z-50"
variant="default"
>
<HelpCircle className="h-6 w-6" />
</Button>
{/* Support Form Dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Contact Support
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Category Selection */}
<div>
<label className="block text-sm font-medium mb-1">
What can we help with? <span className="text-red-500">*</span>
</label>
<Select value={category} onValueChange={setCategory} required>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BUG">Report a Bug</SelectItem>
<SelectItem value="HOW_TO">How-To Question</SelectItem>
<SelectItem value="FEATURE_REQUEST">Feature Request</SelectItem>
<SelectItem value="COMPLIANCE_QUESTION">Compliance Question</SelectItem>
<SelectItem value="INTEGRATION_HELP">Integration Help</SelectItem>
<SelectItem value="PERFORMANCE">Performance Issue</SelectItem>
<SelectItem value="DATA_ISSUE">Data Issue</SelectItem>
<SelectItem value="ACCESS_ISSUE">Access/Login Issue</SelectItem>
<SelectItem value="CONFIGURATION">Configuration Help</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium mb-1">
Priority <span className="text-red-500">*</span>
</label>
<Select value={priority} onValueChange={setPriority} required>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="P0">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span>Critical - System Down</span>
</div>
</SelectItem>
<SelectItem value="P1">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-orange-600" />
<span>Urgent - Major Impact</span>
</div>
</SelectItem>
<SelectItem value="P2">Normal - Standard Request</SelectItem>
<SelectItem value="P3">Low - Enhancement</SelectItem>
</SelectContent>
</Select>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium mb-1">
Subject <span className="text-red-500">*</span>
</label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Brief summary of your issue"
required
minLength={10}
maxLength={200}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium mb-1">
Description <span className="text-red-500">*</span>
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detailed description of your issue or question"
required
minLength={20}
rows={6}
className="resize-none"
/>
</div>
{/* Bug-Specific Fields */}
{category === 'BUG' && (
<div className="space-y-4 p-4 bg-gray-50 rounded-md">
<div>
<label className="block text-sm font-medium mb-1">
Steps to Reproduce <span className="text-red-500">*</span>
</label>
<Textarea
placeholder="1. Go to... 2. Click on... 3. Notice that..."
rows={4}
className="resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Expected Behavior
</label>
<Input placeholder="What should happen?" />
</div>
<div>
<label className="block text-sm font-medium mb-1">
Actual Behavior
</label>
<Input placeholder="What actually happens?" />
</div>
</div>
)}
{/* File Attachments */}
<div>
<label className="block text-sm font-medium mb-1">
Attachments (optional)
</label>
<FileUpload
maxFiles={10}
maxSize={25 * 1024 * 1024}
accept={{
'image/*': ['.png', '.jpg', '.jpeg'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
'text/csv': ['.csv']
}}
onFilesChange={setAttachments}
/>
<p className="text-xs text-gray-500 mt-1">
Maximum 10 files, 25 MB each
</p>
</div>
{/* Current Module Info */}
{currentModule && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm">
<strong>Current Module:</strong> {currentModule}
</div>
)}
{/* Submit Button */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Ticket'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
}
Email-to-Ticket Integration
// Email Processing Service
import { createParser } from 'mailparser';
import { SESClient, ReceiveMessageCommand } from '@aws-sdk/client-ses';
import { prisma } from '@/lib/prisma';
import { generateTicketNumber } from './ticket-utils';
interface ParsedEmail {
from: { address: string; name: string };
to: string[];
subject: string;
text: string;
html: string;
attachments: Array<{
filename: string;
content: Buffer;
contentType: string;
}>;
}
export class EmailToTicketService {
private sesClient: SESClient;
constructor() {
this.sesClient = new SESClient({ region: process.env.AWS_REGION });
}
/**
* Process incoming support emails
* Inbound email address: support@bio-qms.coditect.ai
*/
async processInboundEmail(rawEmail: string): Promise<string> {
const parsed = await this.parseEmail(rawEmail);
// Extract tenant from email address or subject line
const tenant = await this.identifyTenant(parsed);
if (!tenant) {
await this.sendUnknownSenderReply(parsed.from.address);
throw new Error(`Cannot identify tenant for email from ${parsed.from.address}`);
}
// Find or create reporter user
const reporter = await this.findOrCreateReporter(
tenant.id,
parsed.from.address,
parsed.from.name
);
// Extract ticket metadata from email
const metadata = this.extractMetadata(parsed);
// Generate ticket number
const ticketNumber = await generateTicketNumber(tenant.id);
// Create ticket
const ticket = await prisma.supportTicket.create({
data: {
ticketNumber,
tenantId: tenant.id,
reporterId: reporter.id,
reporterEmail: parsed.from.address,
reporterName: parsed.from.name || parsed.from.address,
reporterRole: reporter.role || 'Unknown',
subject: parsed.subject,
description: parsed.text || parsed.html,
category: metadata.category,
priority: metadata.priority,
severity: metadata.severity,
status: 'NEW',
source: 'EMAIL',
subscriptionTier: tenant.subscriptionTier,
// Calculate SLA deadline
slaDeadline: this.calculateSLADeadline(
tenant.subscriptionTier,
metadata.priority
)
}
});
// Process attachments
if (parsed.attachments.length > 0) {
await this.processAttachments(ticket.id, parsed.attachments, reporter.id);
}
// Send confirmation email
await this.sendTicketConfirmation(
parsed.from.address,
ticket.ticketNumber,
ticket.subject
);
return ticket.id;
}
private async parseEmail(rawEmail: string): Promise<ParsedEmail> {
return new Promise((resolve, reject) => {
const parser = createParser();
parser.on('end', (mail) => {
resolve({
from: mail.from.value[0],
to: mail.to.value.map((t: any) => t.address),
subject: mail.subject || 'No Subject',
text: mail.text || '',
html: mail.html || '',
attachments: mail.attachments || []
});
});
parser.on('error', reject);
parser.write(rawEmail);
parser.end();
});
}
private async identifyTenant(email: ParsedEmail): Promise<any> {
// Check for [TENANT-ID] in subject line
const tenantIdMatch = email.subject.match(/\[([A-Z0-9-]+)\]/);
if (tenantIdMatch) {
return prisma.tenant.findUnique({
where: { id: tenantIdMatch[1] }
});
}
// Look up by reporter email domain
const domain = email.from.address.split('@')[1];
return prisma.tenant.findFirst({
where: {
OR: [
{ emailDomain: domain },
{ users: { some: { email: email.from.address } } }
]
}
});
}
private async findOrCreateReporter(
tenantId: string,
email: string,
name: string
): Promise<any> {
let user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
user = await prisma.user.create({
data: {
email,
name: name || email,
tenantId,
role: 'USER',
isActive: true
}
});
}
return user;
}
private extractMetadata(email: ParsedEmail) {
const subject = email.subject.toLowerCase();
const body = (email.text || email.html).toLowerCase();
// Auto-detect category
let category = 'OTHER';
if (subject.includes('bug') || body.includes('error')) {
category = 'BUG';
} else if (subject.includes('feature') || subject.includes('enhancement')) {
category = 'FEATURE_REQUEST';
} else if (subject.includes('how') || subject.includes('help')) {
category = 'HOW_TO';
} else if (subject.includes('compliance') || subject.includes('regulatory')) {
category = 'COMPLIANCE_QUESTION';
} else if (subject.includes('integration') || subject.includes('api')) {
category = 'INTEGRATION_HELP';
}
// Auto-detect priority
let priority = 'P2';
let severity = 'MEDIUM';
if (subject.includes('urgent') || subject.includes('critical')) {
priority = 'P0';
severity = 'CRITICAL';
} else if (subject.includes('important') || subject.includes('asap')) {
priority = 'P1';
severity = 'HIGH';
} else if (subject.includes('low priority') || subject.includes('minor')) {
priority = 'P3';
severity = 'LOW';
}
return { category, priority, severity };
}
private async processAttachments(
ticketId: string,
attachments: any[],
uploadedById: string
) {
for (const attachment of attachments) {
// Upload to S3
const storageKey = await this.uploadToS3(
attachment.content,
attachment.filename,
attachment.contentType
);
// Create attachment record
await prisma.ticketAttachment.create({
data: {
ticketId,
fileName: attachment.filename,
fileSize: attachment.content.length,
mimeType: attachment.contentType,
storageKey,
uploadedById,
scanStatus: 'PENDING'
}
});
}
}
private calculateSLADeadline(tier: string, priority: string): Date {
const slaMinutes = SLA_RESPONSE_TIMES[tier]?.[priority] || 1440; // Default 24 hours
return new Date(Date.now() + slaMinutes * 60 * 1000);
}
private async sendTicketConfirmation(
toEmail: string,
ticketNumber: string,
subject: string
) {
// Send via email service
// Implementation depends on email provider (SendGrid, SES, etc.)
}
private async sendUnknownSenderReply(email: string) {
// Send auto-reply explaining we couldn't identify their account
}
private async uploadToS3(
content: Buffer,
filename: string,
contentType: string
): Promise<string> {
// Upload to S3 and return storage key
// Implementation omitted for brevity
return `tickets/attachments/${Date.now()}-${filename}`;
}
}
// SLA Response Time Matrix (minutes)
const SLA_RESPONSE_TIMES: Record<string, Record<string, number>> = {
'Starter': { P0: 240, P1: 480, P2: 1440, P3: 4320 },
'Professional': { P0: 120, P1: 240, P2: 480, P3: 1440 },
'Enterprise': { P0: 60, P1: 120, P2: 240, P3: 480 },
'Enterprise Priority': { P0: 15, P1: 30, P2: 120, P3: 240 }
};
Slack Integration (Enterprise)
// Slack Bot Integration
import { App, SlackCommandMiddlewareArgs } from '@slack/bolt';
import { prisma } from '@/lib/prisma';
export class SlackSupportBot {
private app: App;
constructor() {
this.app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN
});
this.setupCommands();
this.setupShortcuts();
}
private setupCommands() {
// /support command - Create ticket from Slack
this.app.command('/support', async ({ command, ack, client }) => {
await ack();
try {
// Show ticket creation modal
await client.views.open({
trigger_id: command.trigger_id,
view: {
type: 'modal',
callback_id: 'create_ticket',
title: {
type: 'plain_text',
text: 'Create Support Ticket'
},
submit: {
type: 'plain_text',
text: 'Submit'
},
blocks: [
{
type: 'input',
block_id: 'category',
label: {
type: 'plain_text',
text: 'Category'
},
element: {
type: 'static_select',
action_id: 'category_select',
options: [
{ text: { type: 'plain_text', text: 'Bug' }, value: 'BUG' },
{ text: { type: 'plain_text', text: 'Feature Request' }, value: 'FEATURE_REQUEST' },
{ text: { type: 'plain_text', text: 'How-To' }, value: 'HOW_TO' },
{ text: { type: 'plain_text', text: 'Compliance' }, value: 'COMPLIANCE_QUESTION' }
]
}
},
{
type: 'input',
block_id: 'priority',
label: {
type: 'plain_text',
text: 'Priority'
},
element: {
type: 'static_select',
action_id: 'priority_select',
options: [
{ text: { type: 'plain_text', text: 'Critical (P0)' }, value: 'P0' },
{ text: { type: 'plain_text', text: 'Urgent (P1)' }, value: 'P1' },
{ text: { type: 'plain_text', text: 'Normal (P2)' }, value: 'P2' },
{ text: { type: 'plain_text', text: 'Low (P3)' }, value: 'P3' }
]
}
},
{
type: 'input',
block_id: 'subject',
label: {
type: 'plain_text',
text: 'Subject'
},
element: {
type: 'plain_text_input',
action_id: 'subject_input'
}
},
{
type: 'input',
block_id: 'description',
label: {
type: 'plain_text',
text: 'Description'
},
element: {
type: 'plain_text_input',
action_id: 'description_input',
multiline: true
}
}
]
}
});
} catch (error) {
console.error('Failed to open modal:', error);
}
});
// /ticket <number> - View ticket status
this.app.command('/ticket', async ({ command, ack, say }) => {
await ack();
const ticketNumber = command.text.trim();
if (!ticketNumber) {
await say('Usage: /ticket TKT-2026-00042');
return;
}
const ticket = await prisma.supportTicket.findUnique({
where: { ticketNumber },
include: {
reporter: true,
assignee: true
}
});
if (!ticket) {
await say(`Ticket ${ticketNumber} not found.`);
return;
}
await say({
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `Ticket ${ticket.ticketNumber}`
}
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Status:* ${ticket.status}` },
{ type: 'mrkdwn', text: `*Priority:* ${ticket.priority}` },
{ type: 'mrkdwn', text: `*Category:* ${ticket.category}` },
{ type: 'mrkdwn', text: `*Reporter:* ${ticket.reporter.name}` },
{ type: 'mrkdwn', text: `*Assignee:* ${ticket.assignee?.name || 'Unassigned'}` },
{ type: 'mrkdwn', text: `*Created:* ${ticket.createdAt.toISOString()}` }
]
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Subject:* ${ticket.subject}\n\n${ticket.description}`
}
}
]
});
});
}
private setupShortcuts() {
// Handle ticket creation modal submission
this.app.view('create_ticket', async ({ ack, body, view, client }) => {
await ack();
const values = view.state.values;
const category = values.category.category_select.selected_option?.value;
const priority = values.priority.priority_select.selected_option?.value;
const subject = values.subject.subject_input.value;
const description = values.description.description_input.value;
// Get Slack user info
const userInfo = await client.users.info({ user: body.user.id });
const email = userInfo.user?.profile?.email;
if (!email) {
console.error('Cannot identify user email from Slack');
return;
}
// Find user in BIO-QMS
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
console.error(`User ${email} not found in BIO-QMS`);
return;
}
// Create ticket
const ticketNumber = await generateTicketNumber(user.tenantId);
const ticket = await prisma.supportTicket.create({
data: {
ticketNumber,
tenantId: user.tenantId,
reporterId: user.id,
reporterEmail: email,
reporterName: user.name,
reporterRole: user.role,
subject,
description,
category,
priority,
severity: priority === 'P0' ? 'CRITICAL' : priority === 'P1' ? 'HIGH' : 'MEDIUM',
status: 'NEW',
source: 'SLACK',
subscriptionTier: user.tenant.subscriptionTier,
slaDeadline: this.calculateSLADeadline(user.tenant.subscriptionTier, priority)
}
});
// Send confirmation to Slack channel
await client.chat.postMessage({
channel: body.user.id,
text: `Ticket ${ticket.ticketNumber} created successfully!`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `:white_check_mark: *Ticket Created*\n\nTicket Number: ${ticket.ticketNumber}\nStatus: ${ticket.status}\n\nYou will receive updates as your ticket is processed.`
}
}
]
});
});
}
private calculateSLADeadline(tier: string, priority: string): Date {
const slaMinutes = SLA_RESPONSE_TIMES[tier]?.[priority] || 1440;
return new Date(Date.now() + slaMinutes * 60 * 1000);
}
async start() {
await this.app.start();
console.log('Slack support bot is running');
}
}
const SLA_RESPONSE_TIMES: Record<string, Record<string, number>> = {
'Starter': { P0: 240, P1: 480, P2: 1440, P3: 4320 },
'Professional': { P0: 120, P1: 240, P2: 480, P3: 1440 },
'Enterprise': { P0: 60, P1: 120, P2: 240, P3: 480 },
'Enterprise Priority': { P0: 15, P1: 30, P2: 120, P3: 240 }
};
API for Programmatic Creation
// API Route: POST /api/support/tickets
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { generateTicketNumber } from '@/lib/support/ticket-utils';
import { verifyAPIKey } from '@/lib/auth/api-key';
const createTicketSchema = z.object({
tenantId: z.string().cuid(),
reporterId: z.string().cuid().optional(),
reporterEmail: z.string().email(),
reporterName: z.string().min(1),
subject: z.string().min(10).max(200),
description: z.string().min(20).max(10000),
category: z.enum([
'BUG',
'FEATURE_REQUEST',
'HOW_TO',
'COMPLIANCE_QUESTION',
'INTEGRATION_HELP',
'PERFORMANCE',
'DATA_ISSUE',
'ACCESS_ISSUE',
'CONFIGURATION',
'TRAINING',
'DOCUMENTATION',
'VALIDATION',
'AUDIT_SUPPORT',
'OTHER'
]),
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional(),
affectedModule: z.string().optional(),
stepsToReproduce: z.string().optional(),
expectedBehavior: z.string().optional(),
actualBehavior: z.string().optional(),
browserInfo: z.record(z.any()).optional(),
systemVersion: z.string().optional(),
metadata: z.record(z.any()).optional()
});
export async function POST(request: NextRequest) {
try {
// Verify API key
const apiKey = request.headers.get('x-api-key');
if (!apiKey) {
return NextResponse.json(
{ error: 'API key required' },
{ status: 401 }
);
}
const tenant = await verifyAPIKey(apiKey);
if (!tenant) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const validatedData = createTicketSchema.parse(body);
// Verify tenant match
if (validatedData.tenantId !== tenant.id) {
return NextResponse.json(
{ error: 'Tenant ID mismatch' },
{ status: 403 }
);
}
// Find or create reporter
let reporter = validatedData.reporterId
? await prisma.user.findUnique({ where: { id: validatedData.reporterId } })
: await prisma.user.findUnique({ where: { email: validatedData.reporterEmail } });
if (!reporter) {
reporter = await prisma.user.create({
data: {
email: validatedData.reporterEmail,
name: validatedData.reporterName,
tenantId: tenant.id,
role: 'USER',
isActive: true
}
});
}
// Auto-detect severity if not provided
const severity = validatedData.severity || (
validatedData.priority === 'P0' ? 'CRITICAL' :
validatedData.priority === 'P1' ? 'HIGH' :
validatedData.priority === 'P2' ? 'MEDIUM' : 'LOW'
);
// Generate ticket number
const ticketNumber = await generateTicketNumber(tenant.id);
// Calculate SLA deadline
const slaConfig = await prisma.sLAConfiguration.findUnique({
where: { subscriptionTier: tenant.subscriptionTier }
});
const slaMinutes = slaConfig?.[`${validatedData.priority.toLowerCase()}ResponseTime`] || 1440;
const slaDeadline = new Date(Date.now() + slaMinutes * 60 * 1000);
// Create ticket
const ticket = await prisma.supportTicket.create({
data: {
ticketNumber,
tenantId: tenant.id,
reporterId: reporter.id,
reporterEmail: validatedData.reporterEmail,
reporterName: validatedData.reporterName,
reporterRole: reporter.role,
subject: validatedData.subject,
description: validatedData.description,
category: validatedData.category,
priority: validatedData.priority,
severity,
affectedModule: validatedData.affectedModule,
stepsToReproduce: validatedData.stepsToReproduce,
expectedBehavior: validatedData.expectedBehavior,
actualBehavior: validatedData.actualBehavior,
browserInfo: validatedData.browserInfo,
systemVersion: validatedData.systemVersion,
status: 'NEW',
source: 'API',
subscriptionTier: tenant.subscriptionTier,
slaDeadline,
slaStatus: 'ON_TRACK'
},
include: {
reporter: {
select: {
id: true,
name: true,
email: true
}
}
}
});
// Trigger routing workflow
await triggerTicketRouting(ticket.id);
return NextResponse.json({
success: true,
ticket: {
id: ticket.id,
ticketNumber: ticket.ticketNumber,
status: ticket.status,
priority: ticket.priority,
category: ticket.category,
subject: ticket.subject,
slaDeadline: ticket.slaDeadline,
reporter: ticket.reporter,
createdAt: ticket.createdAt
}
}, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
);
}
console.error('Failed to create ticket:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
async function triggerTicketRouting(ticketId: string) {
// Trigger background job for AI categorization and routing
// Implementation depends on job queue system (BullMQ, etc.)
}
H.2.3: Routing and Escalation
AI Auto-Categorization
// AI-Powered Ticket Categorization
import { Anthropic } from '@anthropic-ai/sdk';
import { prisma } from '@/lib/prisma';
interface CategorizationResult {
category: string;
subcategory: string;
affectedModule: string;
priority: string;
severity: string;
complianceRelated: boolean;
validationImpact: boolean;
regulatoryRisk: string;
suggestedAssignee: string;
confidence: number;
reasoning: string;
}
export class AITicketCategorizer {
private anthropic: Anthropic;
constructor() {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
}
async categorizeTicket(ticketId: string): Promise<CategorizationResult> {
const ticket = await prisma.supportTicket.findUnique({
where: { id: ticketId },
include: {
reporter: true,
tenant: true
}
});
if (!ticket) {
throw new Error(`Ticket ${ticketId} not found`);
}
// Prepare context for Claude
const prompt = this.buildCategorizationPrompt(ticket);
// Call Claude for categorization
const response = await this.anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [
{
role: 'user',
content: prompt
}
]
});
const result = JSON.parse(response.content[0].text) as CategorizationResult;
// Update ticket with AI categorization
await prisma.supportTicket.update({
where: { id: ticketId },
data: {
category: result.category,
subcategory: result.subcategory,
affectedModule: result.affectedModule,
priority: result.priority,
severity: result.severity,
complianceRelated: result.complianceRelated,
validationImpact: result.validationImpact,
regulatoryRisk: result.regulatoryRisk
}
});
return result;
}
private buildCategorizationPrompt(ticket: any): string {
return `You are a technical support AI for BIO-QMS, a Quality Management System for life sciences companies. Analyze the following support ticket and categorize it.
**Ticket Information:**
- Subject: ${ticket.subject}
- Description: ${ticket.description}
- Reporter Role: ${ticket.reporterRole}
- Affected Module: ${ticket.affectedModule || 'Unknown'}
- Steps to Reproduce: ${ticket.stepsToReproduce || 'Not provided'}
- Browser Info: ${JSON.stringify(ticket.browserInfo || {})}
**Categorization Task:**
Analyze this ticket and return a JSON object with the following fields:
1. **category**: One of [BUG, FEATURE_REQUEST, HOW_TO, COMPLIANCE_QUESTION, INTEGRATION_HELP, PERFORMANCE, DATA_ISSUE, ACCESS_ISSUE, CONFIGURATION, TRAINING, DOCUMENTATION, VALIDATION, AUDIT_SUPPORT, OTHER]
2. **subcategory**: More specific classification (e.g., "UI Bug", "API Integration", "21 CFR Part 11 Compliance")
3. **affectedModule**: System module affected (e.g., "Work Orders", "Document Control", "Equipment Management", "Supplier Management", "Training Records", "CAPA", "Audit Management")
4. **priority**: One of [P0, P1, P2, P3]
- P0: System down, data loss, critical compliance violation
- P1: Major function unavailable, workaround exists
- P2: Minor function impaired, minimal impact
- P3: Cosmetic issue, enhancement
5. **severity**: One of [CRITICAL, HIGH, MEDIUM, LOW]
6. **complianceRelated**: Boolean - Does this affect regulatory compliance?
7. **validationImpact**: Boolean - Does this impact system validation status?
8. **regulatoryRisk**: One of [NONE, LOW, MEDIUM, HIGH, CRITICAL]
9. **suggestedAssignee**: Suggested support team [L1_SUPPORT, L2_SUPPORT, ENGINEERING, COMPLIANCE, DEVOPS]
10. **confidence**: Float 0.0-1.0 representing confidence in categorization
11. **reasoning**: Brief explanation of categorization logic
**Critical Indicators:**
- Keywords like "validation", "audit", "FDA", "GMP", "Part 11" → High compliance/regulatory impact
- "Cannot login", "system down", "data lost" → P0 priority
- "Workaround available" → P1/P2 priority
- Questions about "how to" → HOW_TO category, L1_SUPPORT
- Technical errors, stack traces → BUG category, ENGINEERING
- Performance issues → PERFORMANCE category, DEVOPS
Return ONLY valid JSON, no additional text.`;
}
}
// Example Usage
const categorizer = new AITicketCategorizer();
const result = await categorizer.categorizeTicket('ticket_123');
console.log(result);
// {
// "category": "BUG",
// "subcategory": "UI Rendering Issue",
// "affectedModule": "Work Orders",
// "priority": "P1",
// "severity": "HIGH",
// "complianceRelated": false,
// "validationImpact": false,
// "regulatoryRisk": "LOW",
// "suggestedAssignee": "L2_SUPPORT",
// "confidence": 0.92,
// "reasoning": "Reporter describes a UI bug preventing WO approval. Workaround exists (manual approval in admin panel), so P1 not P0."
// }
Tier-Based Routing Engine
// Routing Engine
export class TicketRoutingEngine {
/**
* Route ticket to appropriate support team and agent
*/
async routeTicket(ticketId: string) {
const ticket = await prisma.supportTicket.findUnique({
where: { id: ticketId },
include: {
tenant: true,
reporter: true
}
});
if (!ticket) {
throw new Error(`Ticket ${ticketId} not found`);
}
// Determine support team based on category and complexity
const team = this.determineTeam(ticket);
// Find best available agent
const assignee = await this.findBestAgent(team, ticket);
// Update ticket assignment
await prisma.supportTicket.update({
where: { id: ticketId },
data: {
assignedTeam: team,
assigneeId: assignee?.id,
status: 'TRIAGED'
}
});
// Create status history entry
await prisma.ticketStatusHistory.create({
data: {
ticketId: ticket.id,
fromStatus: ticket.status,
toStatus: 'TRIAGED',
changedById: 'SYSTEM',
reason: `Auto-routed to ${team} team${assignee ? `, assigned to ${assignee.name}` : ''}`
}
});
// Send notification to assignee
if (assignee) {
await this.notifyAssignee(assignee.id, ticket);
}
return { team, assignee };
}
private determineTeam(ticket: any): string {
// Priority-based routing
if (ticket.priority === 'P0' || ticket.severity === 'CRITICAL') {
return 'ENGINEERING'; // Critical issues go straight to engineering
}
// Category-based routing
switch (ticket.category) {
case 'HOW_TO':
case 'TRAINING':
case 'DOCUMENTATION':
return 'L1_SUPPORT'; // General inquiries
case 'COMPLIANCE_QUESTION':
case 'VALIDATION':
case 'AUDIT_SUPPORT':
if (ticket.complianceRelated || ticket.regulatoryRisk === 'HIGH') {
return 'COMPLIANCE'; // Compliance experts
}
return 'L2_SUPPORT';
case 'INTEGRATION_HELP':
case 'CONFIGURATION':
return 'L2_SUPPORT'; // Technical specialists
case 'BUG':
case 'PERFORMANCE':
if (ticket.severity === 'HIGH' || ticket.severity === 'CRITICAL') {
return 'ENGINEERING';
}
return 'L2_SUPPORT';
case 'DATA_ISSUE':
return 'DEVOPS'; // Database/infrastructure team
case 'ACCESS_ISSUE':
return 'L1_SUPPORT';
case 'FEATURE_REQUEST':
return 'L2_SUPPORT'; // L2 will triage and escalate to engineering
default:
return 'L1_SUPPORT';
}
}
private async findBestAgent(team: string, ticket: any) {
// Find agents on this team
const agents = await prisma.user.findMany({
where: {
role: {
in: ['SUPPORT_AGENT', 'SUPPORT_LEAD', 'ENGINEER']
},
supportTeam: team,
isActive: true,
isAvailable: true
},
include: {
_count: {
select: {
assignedTickets: {
where: {
status: {
in: ['NEW', 'TRIAGED', 'IN_PROGRESS', 'ESCALATED']
}
}
}
}
}
}
});
if (agents.length === 0) {
return null; // No agents available
}
// Sort by current workload (fewest active tickets)
agents.sort((a, b) =>
a._count.assignedTickets - b._count.assignedTickets
);
// Additional logic: match agent expertise to ticket category
const expertMatch = agents.find(agent =>
agent.expertise?.includes(ticket.category)
);
return expertMatch || agents[0];
}
private async notifyAssignee(userId: string, ticket: any) {
// Send email notification
// Send in-app notification
// Send Slack DM if Slack integration enabled
}
}
SLA Breach Auto-Escalation
// SLA Monitor and Auto-Escalation Service
import { CronJob } from 'cron';
import { prisma } from '@/lib/prisma';
export class SLAMonitorService {
private cronJob: CronJob;
constructor() {
// Run every 5 minutes
this.cronJob = new CronJob('*/5 * * * *', () => this.checkSLABreaches());
}
async start() {
this.cronJob.start();
console.log('SLA monitoring service started');
}
async stop() {
this.cronJob.stop();
}
/**
* Check all active tickets for SLA status and escalate if needed
*/
async checkSLABreaches() {
const now = new Date();
// Find tickets approaching SLA deadline (75% consumed)
const atRiskTickets = await prisma.supportTicket.findMany({
where: {
status: {
in: ['NEW', 'TRIAGED', 'IN_PROGRESS', 'ESCALATED']
},
slaStatus: 'ON_TRACK',
slaDeadline: {
lte: new Date(now.getTime() + 15 * 60 * 1000) // 15 minutes from now
}
},
include: {
assignee: true,
tenant: true
}
});
for (const ticket of atRiskTickets) {
await this.markAtRisk(ticket);
}
// Find tickets that have breached SLA
const breachedTickets = await prisma.supportTicket.findMany({
where: {
status: {
in: ['NEW', 'TRIAGED', 'IN_PROGRESS', 'ESCALATED']
},
slaStatus: {
in: ['ON_TRACK', 'AT_RISK']
},
slaDeadline: {
lte: now
}
},
include: {
assignee: true,
tenant: true
}
});
for (const ticket of breachedTickets) {
await this.handleSLABreach(ticket);
}
}
private async markAtRisk(ticket: any) {
await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
slaStatus: 'AT_RISK'
}
});
// Notify assignee and their manager
await this.notifyAtRisk(ticket);
console.log(`Ticket ${ticket.ticketNumber} marked AT_RISK`);
}
private async handleSLABreach(ticket: any) {
// Mark as breached
await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
slaStatus: 'BREACHED'
}
});
// Auto-escalate based on current team
const escalationPath = this.getEscalationPath(ticket);
if (escalationPath) {
await this.escalateTicket(ticket, escalationPath);
}
// Notify customer about breach
await this.notifyCustomerSLABreach(ticket);
// Notify leadership
await this.notifyLeadershipSLABreach(ticket);
console.log(`Ticket ${ticket.ticketNumber} BREACHED SLA - escalated to ${escalationPath}`);
}
private getEscalationPath(ticket: any): string | null {
const currentTeam = ticket.assignedTeam;
const escalationLevel = ticket.escalationLevel;
// Escalation ladder: L1 → L2 → Engineering → Leadership
if (currentTeam === 'L1_SUPPORT' && escalationLevel === 0) {
return 'L2_SUPPORT';
} else if (currentTeam === 'L2_SUPPORT' && escalationLevel <= 1) {
return 'ENGINEERING';
} else if (escalationLevel <= 2) {
return 'CUSTOMER_SUCCESS'; // Executive escalation
}
return null; // Already at highest level
}
private async escalateTicket(ticket: any, toTeam: string) {
const routingEngine = new TicketRoutingEngine();
// Find best agent on escalated team
const newAssignee = await routingEngine.findBestAgent(toTeam, ticket);
// Update ticket
await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
assignedTeam: toTeam,
assigneeId: newAssignee?.id,
escalationLevel: {
increment: 1
},
status: 'ESCALATED'
}
});
// Log escalation
await prisma.ticketEscalation.create({
data: {
ticketId: ticket.id,
fromTeam: ticket.assignedTeam,
toTeam,
fromAssigneeId: ticket.assigneeId,
toAssigneeId: newAssignee?.id,
reason: 'SLA_BREACH',
notes: `Auto-escalated due to SLA breach. Original deadline: ${ticket.slaDeadline}`,
escalatedById: 'SYSTEM'
}
});
// Notify new assignee
if (newAssignee) {
await this.notifyEscalation(newAssignee.id, ticket);
}
}
private async notifyAtRisk(ticket: any) {
// Implementation: Send alerts via email, Slack, SMS
}
private async notifyCustomerSLABreach(ticket: any) {
// Implementation: Send apology email with compensation offer (if applicable)
}
private async notifyLeadershipSLABreach(ticket: any) {
// Implementation: Alert support manager and VP Customer Success
}
private async notifyEscalation(userId: string, ticket: any) {
// Implementation: Urgent notification to escalated assignee
}
}
H.2.4: Support Agent Dashboard
Dashboard UI Component
// Support Agent Dashboard Component
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Clock, AlertCircle, CheckCircle, User, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface Ticket {
id: string;
ticketNumber: string;
subject: string;
status: string;
priority: string;
category: string;
reporter: {
name: string;
email: string;
};
slaDeadline: Date;
slaStatus: string;
createdAt: Date;
unreadComments: number;
}
export function SupportDashboard() {
const [myTickets, setMyTickets] = useState<Ticket[]>([]);
const [queueTickets, setQueueTickets] = useState<Ticket[]>([]);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [activeTab, setActiveTab] = useState('my-tickets');
useEffect(() => {
loadTickets();
// Refresh every 30 seconds
const interval = setInterval(loadTickets, 30000);
return () => clearInterval(interval);
}, []);
const loadTickets = async () => {
// Load my assigned tickets
const myResponse = await fetch('/api/support/tickets/my-tickets');
const myData = await myResponse.json();
setMyTickets(myData.tickets);
// Load unassigned queue tickets
const queueResponse = await fetch('/api/support/tickets/queue');
const queueData = await queueResponse.json();
setQueueTickets(queueData.tickets);
};
const handleClaimTicket = async (ticketId: string) => {
await fetch(`/api/support/tickets/${ticketId}/claim`, { method: 'POST' });
loadTickets();
};
return (
<div className="flex h-screen bg-gray-50">
{/* Left Sidebar - Ticket List */}
<div className="w-1/3 border-r bg-white overflow-y-auto">
<div className="p-4 border-b">
<h1 className="text-2xl font-bold">Support Dashboard</h1>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full">
<TabsTrigger value="my-tickets" className="flex-1">
My Tickets ({myTickets.length})
</TabsTrigger>
<TabsTrigger value="queue" className="flex-1">
Queue ({queueTickets.length})
</TabsTrigger>
</TabsList>
<TabsContent value="my-tickets" className="mt-0">
<TicketList
tickets={myTickets}
selectedTicketId={selectedTicket?.id}
onSelectTicket={setSelectedTicket}
/>
</TabsContent>
<TabsContent value="queue" className="mt-0">
<TicketList
tickets={queueTickets}
selectedTicketId={selectedTicket?.id}
onSelectTicket={setSelectedTicket}
showClaimButton
onClaimTicket={handleClaimTicket}
/>
</TabsContent>
</Tabs>
</div>
{/* Right Panel - Ticket Details + Customer Context */}
<div className="flex-1 overflow-y-auto">
{selectedTicket ? (
<TicketDetailView ticket={selectedTicket} onRefresh={loadTickets} />
) : (
<div className="flex items-center justify-center h-full text-gray-400">
Select a ticket to view details
</div>
)}
</div>
</div>
);
}
interface TicketListProps {
tickets: Ticket[];
selectedTicketId?: string;
onSelectTicket: (ticket: Ticket) => void;
showClaimButton?: boolean;
onClaimTicket?: (ticketId: string) => void;
}
function TicketList({
tickets,
selectedTicketId,
onSelectTicket,
showClaimButton,
onClaimTicket
}: TicketListProps) {
return (
<div className="divide-y">
{tickets.map((ticket) => (
<div
key={ticket.id}
className={`p-4 cursor-pointer hover:bg-gray-50 ${
selectedTicketId === ticket.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''
}`}
onClick={() => onSelectTicket(ticket)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-gray-600">
{ticket.ticketNumber}
</span>
<PriorityBadge priority={ticket.priority} />
{ticket.unreadComments > 0 && (
<Badge variant="secondary" className="text-xs">
{ticket.unreadComments} new
</Badge>
)}
</div>
<h3 className="font-medium line-clamp-2">{ticket.subject}</h3>
<p className="text-sm text-gray-600 mt-1">
{ticket.reporter.name}
</p>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<SLATimer slaDeadline={ticket.slaDeadline} slaStatus={ticket.slaStatus} />
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>
{showClaimButton && onClaimTicket && (
<Button
size="sm"
className="w-full mt-2"
onClick={(e) => {
e.stopPropagation();
onClaimTicket(ticket.id);
}}
>
Claim Ticket
</Button>
)}
</div>
))}
{tickets.length === 0 && (
<div className="p-8 text-center text-gray-400">
No tickets in this queue
</div>
)}
</div>
);
}
function PriorityBadge({ priority }: { priority: string }) {
const variants: Record<string, { color: string; icon: React.ReactNode }> = {
P0: { color: 'bg-red-600 text-white', icon: <AlertCircle className="h-3 w-3" /> },
P1: { color: 'bg-orange-600 text-white', icon: <AlertCircle className="h-3 w-3" /> },
P2: { color: 'bg-blue-600 text-white', icon: null },
P3: { color: 'bg-gray-600 text-white', icon: null }
};
const variant = variants[priority] || variants.P2;
return (
<Badge className={`${variant.color} flex items-center gap-1`}>
{variant.icon}
{priority}
</Badge>
);
}
function SLATimer({ slaDeadline, slaStatus }: { slaDeadline: Date; slaStatus: string }) {
const deadline = new Date(slaDeadline);
const now = new Date();
const remaining = deadline.getTime() - now.getTime();
const minutesRemaining = Math.floor(remaining / (60 * 1000));
if (slaStatus === 'BREACHED') {
return (
<div className="flex items-center gap-1 text-red-600 text-xs font-medium">
<AlertCircle className="h-3 w-3" />
SLA BREACHED
</div>
);
}
if (slaStatus === 'AT_RISK' || minutesRemaining < 60) {
return (
<div className="flex items-center gap-1 text-orange-600 text-xs font-medium">
<Clock className="h-3 w-3" />
{minutesRemaining}m remaining
</div>
);
}
return (
<div className="flex items-center gap-1 text-green-600 text-xs">
<CheckCircle className="h-3 w-3" />
{Math.floor(minutesRemaining / 60)}h remaining
</div>
);
}
interface TicketDetailViewProps {
ticket: Ticket;
onRefresh: () => void;
}
function TicketDetailView({ ticket, onRefresh }: TicketDetailViewProps) {
const [customerContext, setCustomerContext] = useState<any>(null);
const [comments, setComments] = useState<any[]>([]);
const [newComment, setNewComment] = useState('');
useEffect(() => {
loadTicketDetails();
loadCustomerContext();
}, [ticket.id]);
const loadTicketDetails = async () => {
const response = await fetch(`/api/support/tickets/${ticket.id}`);
const data = await response.json();
setComments(data.comments);
};
const loadCustomerContext = async () => {
const response = await fetch(`/api/support/tickets/${ticket.id}/context`);
const data = await response.json();
setCustomerContext(data);
};
const handleAddComment = async () => {
await fetch(`/api/support/tickets/${ticket.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newComment })
});
setNewComment('');
loadTicketDetails();
onRefresh();
};
return (
<div className="h-full flex flex-col">
{/* Ticket Header */}
<div className="p-6 border-b bg-white">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold mb-2">{ticket.subject}</h2>
<div className="flex items-center gap-3">
<span className="font-mono text-sm text-gray-600">
{ticket.ticketNumber}
</span>
<PriorityBadge priority={ticket.priority} />
<Badge>{ticket.category}</Badge>
<Badge variant="outline">{ticket.status}</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline">Escalate</Button>
<Button variant="outline">Transfer</Button>
<Button>Resolve</Button>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<span className="text-sm text-gray-600">Reporter</span>
<div className="font-medium">{ticket.reporter.name}</div>
<div className="text-sm text-gray-600">{ticket.reporter.email}</div>
</div>
<div>
<span className="text-sm text-gray-600">Created</span>
<div className="font-medium">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</div>
</div>
<div>
<span className="text-sm text-gray-600">SLA Deadline</span>
<SLATimer slaDeadline={ticket.slaDeadline} slaStatus={ticket.slaStatus} />
</div>
</div>
</div>
{/* Customer Context Panel */}
{customerContext && (
<CustomerContextPanel context={customerContext} />
)}
{/* Ticket Conversation */}
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">
<div className="space-y-4">
{comments.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
</div>
{/* Reply Box */}
<div className="p-4 border-t bg-white">
<textarea
className="w-full p-3 border rounded-md resize-none"
rows={4}
placeholder="Type your response..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<div className="flex justify-between mt-2">
<div className="flex gap-2">
<Button variant="outline" size="sm">Insert Canned Response</Button>
<Button variant="outline" size="sm">Add Attachment</Button>
</div>
<Button onClick={handleAddComment} disabled={!newComment.trim()}>
Send Reply
</Button>
</div>
</div>
</div>
);
}
function CustomerContextPanel({ context }: { context: any }) {
return (
<div className="p-4 bg-blue-50 border-b">
<h3 className="font-semibold mb-2">Customer Context</h3>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-600">Subscription</span>
<div className="font-medium">{context.subscriptionTier}</div>
</div>
<div>
<span className="text-gray-600">Health Score</span>
<div className={`font-medium ${context.healthScore > 75 ? 'text-green-600' : 'text-orange-600'}`}>
{context.healthScore}/100
</div>
</div>
<div>
<span className="text-gray-600">Total Tickets</span>
<div className="font-medium">{context.totalTickets}</div>
</div>
<div>
<span className="text-gray-600">Recent WOs</span>
<div className="font-medium">{context.recentWorkOrders}</div>
</div>
</div>
</div>
);
}
function CommentCard({ comment }: { comment: any }) {
return (
<Card>
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<User className="h-4 w-4" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{comment.author.name}</span>
<Badge variant="secondary">{comment.authorType}</Badge>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
</CardContent>
</Card>
);
}
Canned Responses System
// Canned Response Management
model CannedResponse {
id String @id @default(cuid())
name String
shortcode String @unique // e.g., "/password-reset"
content String @db.Text
category String
tags String[]
language String @default("en")
usageCount Int @default(0)
lastUsedAt DateTime?
isActive Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([shortcode])
@@map("canned_responses")
}
// Seed data
const CANNED_RESPONSES = [
{
name: "Password Reset Instructions",
shortcode: "/password-reset",
category: "ACCESS_ISSUE",
content: `Hello {{reporter_name}},
To reset your password:
1. Go to the login page
2. Click "Forgot Password?"
3. Enter your email address
4. Check your inbox for the reset link (it may take a few minutes)
5. Click the link and set your new password
If you don't receive the email within 10 minutes, please check your spam folder.
Let me know if you need any further assistance!
Best regards,
{{agent_name}}
{{company_name}} Support Team`
},
{
name: "Work Order Approval Process",
shortcode: "/wo-approval",
category: "HOW_TO",
content: `Hello {{reporter_name}},
To approve a Work Order in BIO-QMS:
1. Navigate to Work Orders > Pending Approvals
2. Click on the WO number to open details
3. Review all sections: Scope, Materials, Equipment, Personnel
4. If satisfactory, click "Approve" at the top right
5. Add approval comments (optional but recommended)
6. Click "Confirm Approval"
The WO will then move to the next approval stage or to "Ready to Execute" status if this was the final approval.
Documentation: {{doc_link_wo_approval}}
Let me know if you have any questions!
Best regards,
{{agent_name}}`
},
{
name: "Bug Report Acknowledgment",
shortcode: "/bug-ack",
category: "BUG",
content: `Hello {{reporter_name}},
Thank you for reporting this issue. I've logged it as ticket {{ticket_number}} and our engineering team has been notified.
We will:
1. Investigate the root cause
2. Provide a workaround if available
3. Include a fix in our next release
You'll receive updates as we make progress. Expected resolution timeframe: {{resolution_timeframe}}.
If this is blocking critical work, please let me know immediately and we can escalate.
Best regards,
{{agent_name}}`
},
{
name: "Compliance Question - Part 11",
shortcode: "/21cfr11",
category: "COMPLIANCE_QUESTION",
content: `Hello {{reporter_name}},
Regarding your question about 21 CFR Part 11 compliance:
BIO-QMS implements the following Part 11 controls:
- Electronic signatures with unique user authentication
- Audit trails for all record changes (who, what, when)
- Record retention and archival with data integrity checks
- System validation documentation (IQ/OQ/PQ)
- User access controls and role-based permissions
For specific validation evidence or compliance documentation, please contact your Customer Success Manager or email compliance@bio-qms.coditect.ai.
Our Compliance Guide is available at: {{doc_link_compliance}}
Best regards,
{{agent_name}}
{{company_name}} Compliance Team`
},
{
name: "Feature Request Logged",
shortcode: "/feature-logged",
category: "FEATURE_REQUEST",
content: `Hello {{reporter_name}},
Thank you for this feature request! I've logged it in our product roadmap for review by our product team.
Feature requests are evaluated based on:
- Customer demand (number of requests)
- Alignment with product strategy
- Regulatory/compliance value
- Implementation complexity
You can track this request at: {{feature_tracker_link}}
While I can't provide a specific delivery date, we'll notify you if this feature is scheduled for development.
Best regards,
{{agent_name}}`
}
];
H.2.5: Self-Service Knowledge Base
Knowledge Base Schema
// Knowledge Base Article Model
model KnowledgeArticle {
id String @id @default(cuid())
slug String @unique
title String
summary String @db.Text
content String @db.Text // Markdown content
category String
tags String[]
keywords String[] // For search optimization
// Targeting
audience String[] // QA_MANAGER, REGULATORY, OPERATOR, ADMIN
affectedModules String[]
productVersion String?
// Status
status ArticleStatus @default(DRAFT)
publishedAt DateTime?
archivedAt DateTime?
// Authoring
authorId String
author User @relation("ArticleAuthor", fields: [authorId], references: [id])
reviewerId String?
reviewer User? @relation("ArticleReviewer", fields: [reviewerId], references: [id])
lastReviewedAt DateTime?
// Analytics
viewCount Int @default(0)
helpfulCount Int @default(0)
notHelpfulCount Int @default(0)
deflectedTickets Int @default(0) // Tickets avoided via this article
// Related
relatedArticles String[] // Array of article IDs
tickets SupportTicket[]
feedback ArticleFeedback[]
// SEO
metaDescription String?
ogImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category, status])
@@index([slug])
@@index([tags])
@@map("knowledge_articles")
}
enum ArticleStatus {
DRAFT
UNDER_REVIEW
PUBLISHED
ARCHIVED
}
// Article Feedback
model ArticleFeedback {
id String @id @default(cuid())
articleId String
article KnowledgeArticle @relation(fields: [articleId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id])
helpful Boolean // Was this article helpful?
comment String? @db.Text
createdAt DateTime @default(now())
@@index([articleId])
@@map("article_feedback")
}
// Article View Tracking
model ArticleView {
id String @id @default(cuid())
articleId String
userId String?
sessionId String?
source String? // SEARCH, RELATED, TICKET, DIRECT
searchQuery String?
timeOnPage Int? // seconds
scrollDepth Float? // percentage
createdAt DateTime @default(now())
@@index([articleId, createdAt])
@@map("article_views")
}
AI-Powered Article Search
// AI-Enhanced Knowledge Base Search
import { Anthropic } from '@anthropic-ai/sdk';
import { prisma } from '@/lib/prisma';
export class KnowledgeBaseSearch {
private anthropic: Anthropic;
constructor() {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
}
/**
* Semantic search with query understanding and reranking
*/
async search(query: string, filters?: {
category?: string;
audience?: string;
module?: string;
}) {
// Step 1: Query understanding and expansion
const enhancedQuery = await this.enhanceQuery(query);
// Step 2: Initial keyword search
const candidateArticles = await this.keywordSearch(enhancedQuery, filters);
// Step 3: Semantic reranking with Claude
const rankedArticles = await this.semanticRerank(query, candidateArticles);
// Step 4: Track search for analytics
await this.trackSearch(query, rankedArticles);
return rankedArticles;
}
private async enhanceQuery(query: string): Promise<string[]> {
const response = await this.anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 512,
messages: [
{
role: 'user',
content: `You are a search query enhancer for a Quality Management System knowledge base.
User Query: "${query}"
Generate:
1. The core intent (what is the user trying to accomplish?)
2. 3-5 related search terms or synonyms
3. Relevant module names if applicable
Return JSON:
{
"intent": "...",
"terms": ["term1", "term2", ...],
"modules": ["module1", ...]
}`
}
]
});
const parsed = JSON.parse(response.content[0].text);
return [query, ...parsed.terms];
}
private async keywordSearch(queries: string[], filters?: any) {
const where: any = {
status: 'PUBLISHED',
OR: queries.map(q => ({
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ summary: { contains: q, mode: 'insensitive' } },
{ content: { contains: q, mode: 'insensitive' } },
{ keywords: { has: q.toLowerCase() } }
]
}))
};
if (filters?.category) {
where.category = filters.category;
}
if (filters?.audience) {
where.audience = { has: filters.audience };
}
if (filters?.module) {
where.affectedModules = { has: filters.module };
}
return prisma.knowledgeArticle.findMany({
where,
take: 20,
orderBy: {
viewCount: 'desc'
},
select: {
id: true,
slug: true,
title: true,
summary: true,
category: true,
tags: true,
viewCount: true,
helpfulCount: true,
notHelpfulCount: true
}
});
}
private async semanticRerank(query: string, articles: any[]) {
if (articles.length === 0) return [];
const response = await this.anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [
{
role: 'user',
content: `You are a knowledge base search result ranker.
User Query: "${query}"
Articles (in order of keyword relevance):
${articles.map((a, idx) => `${idx + 1}. [${a.id}] ${a.title}\n ${a.summary}`).join('\n\n')}
Rerank these articles by semantic relevance to the query. Consider:
- How directly the article answers the question
- Completeness of the answer
- Specificity vs generality
Return JSON array of article IDs in order of relevance (most relevant first):
["id1", "id2", ...]`
}
]
});
const rankedIds = JSON.parse(response.content[0].text);
// Reorder articles based on Claude's ranking
const articleMap = new Map(articles.map(a => [a.id, a]));
return rankedIds.map((id: string) => articleMap.get(id)).filter(Boolean);
}
private async trackSearch(query: string, results: any[]) {
await prisma.knowledgeSearch.create({
data: {
query,
resultCount: results.length,
topResultId: results[0]?.id,
timestamp: new Date()
}
});
}
}
Ticket Deflection Tracking
// Ticket Deflection Analytics
export class TicketDeflectionTracker {
/**
* Track when a user views KB article from ticket creation flow
*/
async trackArticleView(params: {
articleId: string;
userId?: string;
sessionId: string;
source: 'SEARCH' | 'RELATED' | 'SUGGESTED';
searchQuery?: string;
ticketDraft?: {
category: string;
subject: string;
description: string;
};
}) {
await prisma.articleView.create({
data: {
articleId: params.articleId,
userId: params.userId,
sessionId: params.sessionId,
source: params.source,
searchQuery: params.searchQuery
}
});
// If article was viewed in ticket creation flow, increment view count
await prisma.knowledgeArticle.update({
where: { id: params.articleId },
data: {
viewCount: { increment: 1 }
}
});
}
/**
* Mark ticket as deflected when user abandons ticket creation after viewing article
*/
async markTicketDeflected(params: {
articleId: string;
sessionId: string;
userId?: string;
}) {
// Increment deflection counter
await prisma.knowledgeArticle.update({
where: { id: params.articleId },
data: {
deflectedTickets: { increment: 1 }
}
});
// Log deflection event
await prisma.ticketDeflection.create({
data: {
articleId: params.articleId,
sessionId: params.sessionId,
userId: params.userId,
deflectedAt: new Date()
}
});
}
/**
* Calculate deflection rate for reporting
*/
async getDeflectionMetrics(startDate: Date, endDate: Date) {
// Articles viewed during ticket creation
const articleViews = await prisma.articleView.count({
where: {
createdAt: { gte: startDate, lte: endDate },
source: { in: ['SEARCH', 'SUGGESTED'] }
}
});
// Tickets actually created
const ticketsCreated = await prisma.supportTicket.count({
where: {
createdAt: { gte: startDate, lte: endDate }
}
});
// Tickets deflected
const ticketsDeflected = await prisma.ticketDeflection.count({
where: {
deflectedAt: { gte: startDate, lte: endDate }
}
});
const deflectionRate = ticketsDeflected / (ticketsCreated + ticketsDeflected);
// Top performing articles
const topArticles = await prisma.knowledgeArticle.findMany({
where: {
deflectedTickets: { gt: 0 }
},
orderBy: {
deflectedTickets: 'desc'
},
take: 10,
select: {
id: true,
title: true,
category: true,
deflectedTickets: true,
viewCount: true
}
});
return {
articleViews,
ticketsCreated,
ticketsDeflected,
deflectionRate: (deflectionRate * 100).toFixed(2) + '%',
estimatedCostSavings: ticketsDeflected * 25, // Assume $25 per ticket
topArticles
};
}
}
Knowledge Base UI Integration
// In-App KB Widget
export function KnowledgeBaseWidget() {
const [query, setQuery] = useState('');
const [articles, setArticles] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const handleSearch = async () => {
setLoading(true);
try {
const response = await fetch('/api/knowledge-base/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await response.json();
setArticles(data.articles);
} finally {
setLoading(false);
}
};
return (
<div className="p-4">
<h2 className="text-xl font-bold mb-4">Knowledge Base</h2>
<div className="flex gap-2 mb-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search help articles..."
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch} disabled={loading}>
Search
</Button>
</div>
{loading && <div>Searching...</div>}
{articles.length > 0 && (
<div className="space-y-4">
{articles.map((article) => (
<Card key={article.id}>
<CardHeader>
<CardTitle className="text-lg">
<a href={`/help/${article.slug}`} className="hover:underline">
{article.title}
</a>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-2">{article.summary}</p>
<div className="flex items-center gap-2">
<Badge>{article.category}</Badge>
{article.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
Implementation Checklist
-
H.2.1: Ticket Data Model
- Prisma schema implementation
- SLA configuration seeding
- Ticket number generation
- Database migrations
- Validation rules implementation
-
H.2.2: Multi-Channel Intake
- In-app widget component
- Email-to-ticket service (SES integration)
- Slack bot setup (Enterprise tier)
- API endpoint for programmatic creation
- File upload handling (S3)
- Browser info capture
-
H.2.3: Routing and Escalation
- AI categorization service (Claude integration)
- Tier-based routing engine
- SLA monitoring cron job
- Auto-escalation workflow
- Notification system (email, Slack, in-app)
-
H.2.4: Support Agent Dashboard
- Dashboard UI component
- Ticket list with SLA timers
- Customer context panel
- Canned responses system
- Comment/reply interface
- Status change workflows
-
H.2.5: Self-Service Knowledge Base
- KB article schema
- Article authoring interface
- AI-powered search
- Semantic reranking
- Ticket deflection tracking
- Analytics dashboard
Testing Requirements
Unit Tests
// Example: SLA Calculation Tests
describe('SLACalculator', () => {
it('should calculate correct deadline for Enterprise P0 ticket', () => {
const deadline = calculateSLADeadline('Enterprise', 'P0');
const expectedTime = Date.now() + 60 * 60 * 1000; // 1 hour
expect(deadline.getTime()).toBeCloseTo(expectedTime, -4);
});
it('should account for business hours only', () => {
// Test SLA calculation excluding weekends and nights
});
it('should pause SLA when waiting on customer', () => {
// Test SLA timer pause functionality
});
});
Integration Tests
// Example: End-to-End Ticket Flow
describe('Support Ticketing Flow', () => {
it('should create ticket from in-app widget', async () => {
const ticket = await createTicketViaAPI({
subject: 'Cannot approve work order',
description: 'Getting error when clicking Approve button',
category: 'BUG',
priority: 'P1'
});
expect(ticket.ticketNumber).toMatch(/TKT-\d{4}-\d{5}/);
expect(ticket.status).toBe('NEW');
});
it('should auto-categorize and route ticket', async () => {
// Test AI categorization and routing
});
it('should escalate ticket on SLA breach', async () => {
// Test auto-escalation workflow
});
});
Load Testing
- Concurrent ticket creation: 100 tickets/minute
- Dashboard refresh rate: 30 seconds
- SLA check frequency: Every 5 minutes
- AI categorization latency: <2 seconds
- Search response time: <500ms
Compliance & Audit Trail
All support ticket operations are logged to the audit trail with:
- Ticket creation, updates, status changes
- Comment additions (customer-visible and internal)
- Assignments and escalations
- SLA breaches and resolutions
- File attachments (with virus scan results)
- Customer satisfaction ratings
Audit log retention: 7 years (per 21 CFR Part 11)
Success Metrics
| Metric | Target | Measurement |
|---|---|---|
| SLA Compliance Rate | >95% | % of tickets resolved within SLA |
| First Response Time | <SLA for tier | Average time to first agent response |
| Resolution Time | <SLA for tier | Average time to resolution |
| Ticket Deflection Rate | >30% | % of users who view KB article and don't create ticket |
| Customer Satisfaction | >4.0/5.0 | Average rating from post-resolution survey |
| Escalation Rate | <10% | % of tickets escalated beyond L1 |
| Auto-Categorization Accuracy | >85% | % of AI categorizations accepted by agents |
Deployment Considerations
Infrastructure
- PostgreSQL database for ticket storage
- Redis for SLA monitoring queue
- S3 for attachment storage
- SES for email integration
- BullMQ for background jobs (AI categorization, SLA checks)
Security
- API key authentication for programmatic access
- Rate limiting: 100 requests/minute per tenant
- File upload virus scanning (ClamAV)
- PII encryption for customer contact information
- Role-based access control (RBAC) for support agents
Monitoring
- SLA breach alerts to Slack
- Daily ticket volume reports
- AI categorization accuracy tracking
- Customer satisfaction trends
- Agent performance dashboards
Future Enhancements
- Predictive Analytics: ML model to predict ticket priority and resolution time
- Video Screen Recording: Customers can record screen while describing issue
- Live Chat: Real-time chat for Enterprise Priority customers
- Community Forum: Peer-to-peer support community
- Multilingual Support: Auto-translation of tickets and articles
- Mobile App: iOS/Android support ticket management
- Voice-to-Ticket: Phone support with automatic transcription
- Smart Routing: ML-based agent skill matching
Document Version: 1.0.0 Last Updated: 2026-02-16 Total Lines: 2,247 Authors: Technical Support Agent (Claude Sonnet 4.5) Status: Complete - Ready for Implementation