Skip to main content

Track H: Customer Operations - Evidence Document

Platform: BIO-QMS (FDA 21 CFR Part 11, HIPAA, SOC 2 Type II Compliant) Purpose: Comprehensive customer operations infrastructure for regulated life sciences QMS SaaS Scope: Onboarding, Support, Customer Success, Self-Service, Communication


Table of Contents

  1. H.1: Customer Onboarding Workflow
  2. H.2: Support Ticketing System
  3. H.3: Customer Success & Health Scoring
  4. H.4: Customer Portal & Self-Service
  5. H.5: Customer Communication Engine
  6. Architecture Overview
  7. Database Schema
  8. API Reference
  9. Compliance & Audit

H.1: Customer Onboarding Workflow

H.1.1: Guided Onboarding Flow

Objective: <30 min time-to-first-value, >90% 7-day retention target

NestJS Service Implementation

// src/customer-ops/onboarding/onboarding.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OnboardingStep, OnboardingStatus } from '@prisma/client';

export interface OnboardingFlowConfig {
organizationId: string;
userId: string;
industry: 'pharma' | 'biotech' | 'medtech' | 'cro' | 'cdmo';
companySize: 'startup' | 'smb' | 'enterprise';
primaryUseCase: string[];
expectedUserCount: number;
}

export interface OnboardingProgress {
currentStep: number;
totalSteps: number;
completedSteps: OnboardingStep[];
timeElapsed: number;
estimatedTimeRemaining: number;
blockers: OnboardingBlocker[];
}

export interface OnboardingBlocker {
stepId: string;
type: 'technical' | 'configuration' | 'data' | 'user_action';
severity: 'critical' | 'high' | 'medium' | 'low';
message: string;
suggestedAction: string;
}

@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);

constructor(
private prisma: PrismaService,
private eventEmitter: EventEmitter2,
) {}

/**
* Initialize onboarding journey for new customer
* Target: <2 seconds to first screen
*/
async initializeOnboarding(config: OnboardingFlowConfig): Promise<string> {
this.logger.log(`Initializing onboarding for org: ${config.organizationId}`);

const startTime = Date.now();

// Create onboarding session
const session = await this.prisma.onboardingSession.create({
data: {
organizationId: config.organizationId,
userId: config.userId,
industry: config.industry,
companySize: config.companySize,
primaryUseCase: config.primaryUseCase,
expectedUserCount: config.expectedUserCount,
status: OnboardingStatus.IN_PROGRESS,
startedAt: new Date(),
targetCompletionTime: 30 * 60 * 1000, // 30 minutes in ms
},
});

// Generate personalized step sequence based on industry and use case
const steps = await this.generateOnboardingSteps(config);

await this.prisma.onboardingStep.createMany({
data: steps.map((step, index) => ({
sessionId: session.id,
stepNumber: index + 1,
stepType: step.type,
title: step.title,
description: step.description,
estimatedDuration: step.estimatedDuration,
isRequired: step.isRequired,
isCompleted: false,
})),
});

// Emit onboarding started event
this.eventEmitter.emit('onboarding.started', {
sessionId: session.id,
organizationId: config.organizationId,
userId: config.userId,
timestamp: new Date(),
});

const elapsed = Date.now() - startTime;
this.logger.log(`Onboarding initialized in ${elapsed}ms`);

return session.id;
}

/**
* Generate personalized onboarding steps based on customer profile
* Industry-specific and use-case optimized
*/
private async generateOnboardingSteps(config: OnboardingFlowConfig) {
const baseSteps = [
{
type: 'WELCOME',
title: 'Welcome to BIO-QMS',
description: 'Quick introduction to your new QMS platform',
estimatedDuration: 2 * 60 * 1000, // 2 minutes
isRequired: true,
},
{
type: 'ORGANIZATION_SETUP',
title: 'Configure Your Organization',
description: 'Set up company profile and regulatory requirements',
estimatedDuration: 5 * 60 * 1000,
isRequired: true,
},
{
type: 'USER_MANAGEMENT',
title: 'Invite Your Team',
description: 'Add users and assign roles',
estimatedDuration: 3 * 60 * 1000,
isRequired: false,
},
{
type: 'DOCUMENT_SETUP',
title: 'Create Your First Document',
description: 'Set up document templates and workflows',
estimatedDuration: 8 * 60 * 1000,
isRequired: true,
},
];

// Add industry-specific steps
if (config.industry === 'pharma' && config.primaryUseCase.includes('clinical_trials')) {
baseSteps.push({
type: 'CLINICAL_TRIAL_SETUP',
title: 'Configure Clinical Trial Management',
description: 'Set up study protocols and eTMF structure',
estimatedDuration: 10 * 60 * 1000,
isRequired: false,
});
}

if (config.industry === 'medtech') {
baseSteps.push({
type: 'DHF_SETUP',
title: 'Initialize Design History File',
description: 'Configure DHF structure for device documentation',
estimatedDuration: 7 * 60 * 1000,
isRequired: false,
});
}

baseSteps.push({
type: 'FIRST_WORKFLOW',
title: 'Test Your First Workflow',
description: 'Create and approve a test document',
estimatedDuration: 5 * 60 * 1000,
isRequired: true,
});

baseSteps.push({
type: 'COMPLETION_CELEBRATION',
title: 'You\'re All Set!',
description: 'Review your setup and next steps',
estimatedDuration: 2 * 60 * 1000,
isRequired: true,
});

return baseSteps;
}

/**
* Get current onboarding progress with real-time metrics
*/
async getProgress(sessionId: string): Promise<OnboardingProgress> {
const session = await this.prisma.onboardingSession.findUnique({
where: { id: sessionId },
include: {
steps: {
orderBy: { stepNumber: 'asc' },
},
},
});

if (!session) {
throw new Error(`Onboarding session ${sessionId} not found`);
}

const completedSteps = session.steps.filter(s => s.isCompleted);
const currentStep = session.steps.find(s => !s.isCompleted);
const timeElapsed = Date.now() - session.startedAt.getTime();

// Calculate estimated time remaining based on step durations
const remainingSteps = session.steps.filter(s => !s.isCompleted);
const estimatedTimeRemaining = remainingSteps.reduce(
(sum, step) => sum + step.estimatedDuration,
0
);

// Detect blockers
const blockers = await this.detectBlockers(sessionId);

return {
currentStep: currentStep ? currentStep.stepNumber : session.steps.length,
totalSteps: session.steps.length,
completedSteps: completedSteps as OnboardingStep[],
timeElapsed,
estimatedTimeRemaining,
blockers,
};
}

/**
* Detect blockers preventing onboarding completion
*/
private async detectBlockers(sessionId: string): Promise<OnboardingBlocker[]> {
const blockers: OnboardingBlocker[] = [];

const session = await this.prisma.onboardingSession.findUnique({
where: { id: sessionId },
include: {
organization: {
include: {
users: true,
documents: true,
},
},
steps: true,
},
});

if (!session) return blockers;

// Check for stuck steps (>2x estimated duration)
const currentStep = session.steps.find(s => !s.isCompleted && s.startedAt);
if (currentStep && currentStep.startedAt) {
const timeOnStep = Date.now() - currentStep.startedAt.getTime();
if (timeOnStep > currentStep.estimatedDuration * 2) {
blockers.push({
stepId: currentStep.id,
type: 'user_action',
severity: 'high',
message: `Step "${currentStep.title}" is taking longer than expected`,
suggestedAction: 'Would you like help with this step? Click for guided assistance.',
});
}
}

// Check for missing required data
if (session.organization.users.length === 1) {
blockers.push({
stepId: 'user-management',
type: 'configuration',
severity: 'medium',
message: 'No additional users added',
suggestedAction: 'Invite team members to collaborate on quality management',
});
}

if (session.organization.documents.length === 0) {
const docStep = session.steps.find(s => s.stepType === 'DOCUMENT_SETUP');
if (docStep?.isCompleted) {
blockers.push({
stepId: docStep.id,
type: 'data',
severity: 'critical',
message: 'Document setup marked complete but no documents created',
suggestedAction: 'Create your first document to continue',
});
}
}

return blockers;
}

/**
* Mark step as complete and trigger next step
*/
async completeStep(sessionId: string, stepNumber: number): Promise<void> {
const step = await this.prisma.onboardingStep.findFirst({
where: {
sessionId,
stepNumber,
},
});

if (!step) {
throw new Error(`Step ${stepNumber} not found in session ${sessionId}`);
}

await this.prisma.onboardingStep.update({
where: { id: step.id },
data: {
isCompleted: true,
completedAt: new Date(),
},
});

// Check if onboarding is complete
const session = await this.prisma.onboardingSession.findUnique({
where: { id: sessionId },
include: { steps: true },
});

const allRequiredComplete = session?.steps
.filter(s => s.isRequired)
.every(s => s.isCompleted);

if (allRequiredComplete) {
await this.completeOnboarding(sessionId);
}

this.eventEmitter.emit('onboarding.step.completed', {
sessionId,
stepNumber,
timestamp: new Date(),
});
}

/**
* Complete onboarding and trigger celebration
*/
private async completeOnboarding(sessionId: string): Promise<void> {
const session = await this.prisma.onboardingSession.update({
where: { id: sessionId },
data: {
status: OnboardingStatus.COMPLETED,
completedAt: new Date(),
},
});

const timeToValue = session.completedAt.getTime() - session.startedAt.getTime();

this.eventEmitter.emit('onboarding.completed', {
sessionId,
organizationId: session.organizationId,
userId: session.userId,
timeToValue,
timestamp: new Date(),
});

this.logger.log(
`Onboarding completed for org ${session.organizationId} in ${timeToValue / 1000 / 60} minutes`
);
}
}

React Frontend Component

// src/customer-portal/components/onboarding/OnboardingWizard.tsx
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, AlertCircle, Clock } from 'lucide-react';
import confetti from 'canvas-confetti';

export const OnboardingWizard: React.FC = () => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);

const { data: progress, refetch } = useQuery({
queryKey: ['onboarding-progress'],
queryFn: () => fetch('/api/customer-ops/onboarding/progress').then(r => r.json()),
refetchInterval: 5000, // Poll every 5 seconds
});

const completeMutation = useMutation({
mutationFn: (stepNumber: number) =>
fetch(`/api/customer-ops/onboarding/steps/${stepNumber}/complete`, {
method: 'POST',
}),
onSuccess: () => {
refetch();
},
});

// Trigger confetti on completion
useEffect(() => {
if (progress?.currentStep === progress?.totalSteps) {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
}
}, [progress]);

const progressPercentage = progress
? (progress.completedSteps.length / progress.totalSteps) * 100
: 0;

const formatTime = (ms: number) => {
const minutes = Math.floor(ms / 60000);
return `${minutes} min`;
};

return (
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Welcome to BIO-QMS
</h1>
<p className="text-gray-600">
Let's get you set up in less than 30 minutes
</p>
</div>

{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">
Step {progress?.currentStep || 0} of {progress?.totalSteps || 0}
</span>
<span className="text-sm text-gray-500 flex items-center gap-1">
<Clock className="w-4 h-4" />
{progress?.estimatedTimeRemaining
? formatTime(progress.estimatedTimeRemaining)
: '--'}{' '}
remaining
</span>
</div>
<Progress value={progressPercentage} className="h-2" />
</div>

{/* Blockers Alert */}
{progress?.blockers && progress.blockers.length > 0 && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>{progress.blockers[0].message}</strong>
<p className="mt-1 text-sm">{progress.blockers[0].suggestedAction}</p>
</AlertDescription>
</Alert>
)}

{/* Step Content */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
{/* Dynamic step content based on current step */}
{progress?.completedSteps.length === progress?.totalSteps ? (
<CompletionCelebration />
) : (
<StepContent
step={progress?.currentStep}
onComplete={() => completeMutation.mutate(progress.currentStep)}
/>
)}
</div>

{/* Navigation */}
<div className="flex justify-between">
<Button
variant="outline"
disabled={currentStepIndex === 0}
onClick={() => setCurrentStepIndex(prev => prev - 1)}
>
Previous
</Button>
<Button
onClick={() => completeMutation.mutate(progress.currentStep)}
disabled={completeMutation.isPending}
>
Continue
</Button>
</div>
</div>
);
};

const CompletionCelebration: React.FC = () => {
return (
<div className="text-center py-12">
<div className="mb-4">
<CheckCircle className="w-24 h-24 text-green-500 mx-auto" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
🎉 You're All Set!
</h2>
<p className="text-gray-600 mb-6">
Your QMS platform is ready to use. Here's what you can do next:
</p>
<div className="grid grid-cols-3 gap-4 text-left max-w-2xl mx-auto">
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">Create Documents</h3>
<p className="text-sm text-gray-600">
Start documenting your quality processes
</p>
</div>
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">Invite Team</h3>
<p className="text-sm text-gray-600">
Collaborate with your quality team
</p>
</div>
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">Schedule Training</h3>
<p className="text-sm text-gray-600">
Book a session with your CSM
</p>
</div>
</div>
</div>
);
};

H.1.2: Organization Provisioning Automation

Objective: Automated tenant creation with sample data seeding

// src/customer-ops/onboarding/provisioning.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';

export interface ProvisioningRequest {
organizationName: string;
industry: string;
adminEmail: string;
adminFirstName: string;
adminLastName: string;
subdomain?: string;
enableSampleData: boolean;
}

@Injectable()
export class ProvisioningService {
private readonly logger = new Logger(ProvisioningService.name);

constructor(
private prisma: PrismaService,
private config: ConfigService,
) {}

/**
* Provision new organization with complete setup
* Target: <5 seconds end-to-end
*/
async provisionOrganization(request: ProvisioningRequest): Promise<string> {
this.logger.log(`Provisioning organization: ${request.organizationName}`);

const startTime = Date.now();

// Transaction ensures atomic provisioning
const result = await this.prisma.$transaction(async tx => {
// 1. Create organization
const org = await tx.organization.create({
data: {
name: request.organizationName,
industry: request.industry,
subdomain: request.subdomain || this.generateSubdomain(request.organizationName),
status: 'ACTIVE',
settings: {
create: {
timezone: 'America/New_York',
dateFormat: 'MM/DD/YYYY',
complianceFrameworks: ['FDA_21_CFR_PART_11', 'ISO_13485'],
retentionPolicyYears: 7,
},
},
},
});

// 2. Create admin user
const hashedPassword = await bcrypt.hash(this.generateTempPassword(), 10);
const admin = await tx.user.create({
data: {
email: request.adminEmail,
firstName: request.adminFirstName,
lastName: request.adminLastName,
passwordHash: hashedPassword,
isEmailVerified: false,
organizationId: org.id,
},
});

// 3. Assign admin role
await tx.userRole.create({
data: {
userId: admin.id,
role: 'ORGANIZATION_ADMIN',
grantedBy: admin.id,
},
});

// 4. Create default document types
await this.createDefaultDocumentTypes(tx, org.id);

// 5. Create default workflows
await this.createDefaultWorkflows(tx, org.id);

// 6. Create default approval chains
await this.createDefaultApprovalChains(tx, org.id);

// 7. Seed sample data if requested
if (request.enableSampleData) {
await this.seedSampleData(tx, org.id, admin.id);
}

return org.id;
});

const elapsed = Date.now() - startTime;
this.logger.log(`Organization provisioned in ${elapsed}ms`);

return result;
}

private generateSubdomain(orgName: string): string {
return orgName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.substring(0, 32);
}

private generateTempPassword(): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%';
let password = '';
for (let i = 0; i < 16; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
return password;
}

private async createDefaultDocumentTypes(tx: any, orgId: string): Promise<void> {
const documentTypes = [
{ code: 'SOP', name: 'Standard Operating Procedure', prefix: 'SOP-' },
{ code: 'WI', name: 'Work Instruction', prefix: 'WI-' },
{ code: 'FORM', name: 'Form', prefix: 'FORM-' },
{ code: 'PROTOCOL', name: 'Protocol', prefix: 'PROT-' },
{ code: 'REPORT', name: 'Report', prefix: 'RPT-' },
{ code: 'VALIDATION', name: 'Validation Document', prefix: 'VAL-' },
{ code: 'POLICY', name: 'Policy', prefix: 'POL-' },
];

await tx.documentType.createMany({
data: documentTypes.map(dt => ({
...dt,
organizationId: orgId,
})),
});
}

private async createDefaultWorkflows(tx: any, orgId: string): Promise<void> {
const workflows = [
{
name: 'Standard Document Approval',
description: 'Standard 3-stage approval workflow',
stages: ['Draft', 'Review', 'Approved', 'Effective'],
},
{
name: 'Change Control',
description: 'Change request and approval workflow',
stages: ['Initiated', 'Impact Assessment', 'Approval', 'Implementation', 'Closure'],
},
{
name: 'CAPA Workflow',
description: 'Corrective and Preventive Action workflow',
stages: ['Opened', 'Investigation', 'Action Plan', 'Implementation', 'Verification', 'Closed'],
},
];

for (const workflow of workflows) {
await tx.workflow.create({
data: {
organizationId: orgId,
name: workflow.name,
description: workflow.description,
stages: {
create: workflow.stages.map((stage, index) => ({
name: stage,
order: index,
})),
},
},
});
}
}

private async createDefaultApprovalChains(tx: any, orgId: string): Promise<void> {
await tx.approvalChainTemplate.createMany({
data: [
{
organizationId: orgId,
name: 'Document Owner → QA Review → QA Approval',
description: 'Standard document approval chain',
requiredRoles: ['DOCUMENT_OWNER', 'QA_REVIEWER', 'QA_APPROVER'],
},
{
organizationId: orgId,
name: 'Initiator → Department Head → QA',
description: 'Change control approval chain',
requiredRoles: ['CHANGE_INITIATOR', 'DEPARTMENT_HEAD', 'QA_MANAGER'],
},
],
});
}

private async seedSampleData(tx: any, orgId: string, userId: string): Promise<void> {
// Create sample SOP
await tx.document.create({
data: {
organizationId: orgId,
documentNumber: 'SOP-001',
title: 'Document Control Procedure (Sample)',
version: '1.0',
status: 'DRAFT',
createdBy: userId,
content: {
create: {
sections: [
{
title: 'Purpose',
content: 'This procedure establishes the requirements for document control...',
order: 1,
},
{
title: 'Scope',
content: 'This procedure applies to all quality system documents...',
order: 2,
},
],
},
},
},
});

// Create sample users
const sampleUsers = [
{ email: 'qa.reviewer@example.com', firstName: 'Jane', lastName: 'Smith', role: 'QA_REVIEWER' },
{ email: 'qa.approver@example.com', firstName: 'John', lastName: 'Doe', role: 'QA_APPROVER' },
];

for (const user of sampleUsers) {
const hashedPassword = await bcrypt.hash('ChangeMe123!', 10);
await tx.user.create({
data: {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
passwordHash: hashedPassword,
isEmailVerified: false,
organizationId: orgId,
roles: {
create: {
role: user.role,
grantedBy: userId,
},
},
},
});
}
}
}

H.1.3: Onboarding Checklist Engine

Objective: Progress tracking with contextual help

// src/customer-ops/onboarding/checklist.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';

export interface ChecklistItem {
id: string;
title: string;
description: string;
category: 'setup' | 'configuration' | 'team' | 'content' | 'testing';
priority: 'critical' | 'high' | 'medium' | 'low';
estimatedMinutes: number;
isCompleted: boolean;
completedAt?: Date;
helpArticleUrl?: string;
videoTutorialUrl?: string;
dependsOn?: string[];
}

@Injectable()
export class ChecklistService {
constructor(private prisma: PrismaService) {}

/**
* Generate dynamic checklist based on organization profile
*/
async generateChecklist(organizationId: string): Promise<ChecklistItem[]> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: {
users: true,
documents: true,
settings: true,
},
});

if (!org) {
throw new Error(`Organization ${organizationId} not found`);
}

const items: ChecklistItem[] = [
{
id: 'org-profile',
title: 'Complete Organization Profile',
description: 'Add company details and regulatory information',
category: 'setup',
priority: 'critical',
estimatedMinutes: 5,
isCompleted: !!org.settings,
helpArticleUrl: '/help/organization-setup',
},
{
id: 'invite-users',
title: 'Invite Team Members',
description: 'Add at least 3 users to your organization',
category: 'team',
priority: 'high',
estimatedMinutes: 3,
isCompleted: org.users.length >= 3,
dependsOn: ['org-profile'],
videoTutorialUrl: '/videos/user-management',
},
{
id: 'configure-roles',
title: 'Set Up User Roles',
description: 'Assign roles and permissions to team members',
category: 'configuration',
priority: 'high',
estimatedMinutes: 5,
isCompleted: false,
dependsOn: ['invite-users'],
},
{
id: 'first-document',
title: 'Create First Document',
description: 'Create and save your first quality document',
category: 'content',
priority: 'critical',
estimatedMinutes: 8,
isCompleted: org.documents.length > 0,
dependsOn: ['org-profile'],
videoTutorialUrl: '/videos/document-creation',
},
{
id: 'test-workflow',
title: 'Test Approval Workflow',
description: 'Submit and approve a test document',
category: 'testing',
priority: 'high',
estimatedMinutes: 5,
isCompleted: false,
dependsOn: ['first-document', 'configure-roles'],
},
{
id: 'configure-notifications',
title: 'Set Up Notifications',
description: 'Configure email and in-app notifications',
category: 'configuration',
priority: 'medium',
estimatedMinutes: 3,
isCompleted: false,
},
{
id: 'sso-setup',
title: 'Enable SSO (Optional)',
description: 'Configure single sign-on for your organization',
category: 'configuration',
priority: 'low',
estimatedMinutes: 15,
isCompleted: false,
dependsOn: ['org-profile'],
helpArticleUrl: '/help/sso-configuration',
},
];

return items;
}

/**
* Mark checklist item as complete
*/
async completeItem(
organizationId: string,
itemId: string,
): Promise<void> {
await this.prisma.onboardingChecklistProgress.upsert({
where: {
organizationId_itemId: {
organizationId,
itemId,
},
},
update: {
isCompleted: true,
completedAt: new Date(),
},
create: {
organizationId,
itemId,
isCompleted: true,
completedAt: new Date(),
},
});
}
}

H.1.4: Data Migration Tools

Objective: CSV/Excel import, LIMS integration, legacy migration

// src/customer-ops/onboarding/migration.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import * as XLSX from 'xlsx';
import * as csv from 'csv-parser';
import { createReadStream } from 'fs';
import { Readable } from 'stream';

export interface MigrationJob {
id: string;
organizationId: string;
type: 'csv' | 'excel' | 'lims' | 'legacy_qms';
status: 'pending' | 'processing' | 'completed' | 'failed';
totalRecords: number;
processedRecords: number;
failedRecords: number;
errors: MigrationError[];
startedAt?: Date;
completedAt?: Date;
}

export interface MigrationError {
rowNumber: number;
field: string;
error: string;
rawValue: any;
}

@Injectable()
export class MigrationService {
private readonly logger = new Logger(MigrationService.name);

constructor(private prisma: PrismaService) {}

/**
* Import documents from CSV file
*/
async importFromCSV(
organizationId: string,
fileBuffer: Buffer,
): Promise<string> {
const jobId = await this.createMigrationJob(organizationId, 'csv');

// Parse CSV in background
this.processCSVImport(jobId, organizationId, fileBuffer).catch(err => {
this.logger.error(`CSV import failed: ${err.message}`, err.stack);
});

return jobId;
}

private async processCSVImport(
jobId: string,
organizationId: string,
fileBuffer: Buffer,
): Promise<void> {
await this.updateJobStatus(jobId, 'processing');

const records: any[] = [];
const errors: MigrationError[] = [];

const stream = Readable.from(fileBuffer.toString());

await new Promise((resolve, reject) => {
stream
.pipe(csv())
.on('data', data => records.push(data))
.on('end', resolve)
.on('error', reject);
});

await this.updateJob(jobId, { totalRecords: records.length });

let processed = 0;
let failed = 0;

for (let i = 0; i < records.length; i++) {
try {
await this.importDocument(organizationId, records[i]);
processed++;
} catch (error) {
failed++;
errors.push({
rowNumber: i + 2, // +2 for 1-indexed and header row
field: 'unknown',
error: error.message,
rawValue: records[i],
});
}

// Update progress every 10 records
if (i % 10 === 0) {
await this.updateJob(jobId, {
processedRecords: processed,
failedRecords: failed,
});
}
}

await this.updateJob(jobId, {
status: 'completed',
processedRecords: processed,
failedRecords: failed,
errors,
completedAt: new Date(),
});

this.logger.log(
`CSV import completed: ${processed} success, ${failed} failed`
);
}

/**
* Import documents from Excel file
*/
async importFromExcel(
organizationId: string,
fileBuffer: Buffer,
): Promise<string> {
const jobId = await this.createMigrationJob(organizationId, 'excel');

this.processExcelImport(jobId, organizationId, fileBuffer).catch(err => {
this.logger.error(`Excel import failed: ${err.message}`, err.stack);
});

return jobId;
}

private async processExcelImport(
jobId: string,
organizationId: string,
fileBuffer: Buffer,
): Promise<void> {
await this.updateJobStatus(jobId, 'processing');

const workbook = XLSX.read(fileBuffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const records = XLSX.utils.sheet_to_json(worksheet);

await this.updateJob(jobId, { totalRecords: records.length });

let processed = 0;
let failed = 0;
const errors: MigrationError[] = [];

for (let i = 0; i < records.length; i++) {
try {
await this.importDocument(organizationId, records[i]);
processed++;
} catch (error) {
failed++;
errors.push({
rowNumber: i + 2,
field: 'unknown',
error: error.message,
rawValue: records[i],
});
}

if (i % 10 === 0) {
await this.updateJob(jobId, {
processedRecords: processed,
failedRecords: failed,
});
}
}

await this.updateJob(jobId, {
status: 'completed',
processedRecords: processed,
failedRecords: failed,
errors,
completedAt: new Date(),
});
}

/**
* Import single document from migration record
*/
private async importDocument(
organizationId: string,
record: any,
): Promise<void> {
// Validate required fields
if (!record.documentNumber || !record.title) {
throw new Error('Missing required fields: documentNumber, title');
}

// Create document
await this.prisma.document.create({
data: {
organizationId,
documentNumber: record.documentNumber,
title: record.title,
version: record.version || '1.0',
status: this.mapStatus(record.status),
effectiveDate: record.effectiveDate
? new Date(record.effectiveDate)
: undefined,
content: record.content
? {
create: {
sections: [
{
title: 'Migrated Content',
content: record.content,
order: 1,
},
],
},
}
: undefined,
metadata: {
migrated: true,
migratedAt: new Date(),
sourceSystem: record.sourceSystem,
},
},
});
}

private mapStatus(status: string): string {
const statusMap: Record<string, string> = {
draft: 'DRAFT',
pending: 'PENDING_REVIEW',
approved: 'APPROVED',
active: 'EFFECTIVE',
retired: 'OBSOLETE',
};
return statusMap[status?.toLowerCase()] || 'DRAFT';
}

private async createMigrationJob(
organizationId: string,
type: MigrationJob['type'],
): Promise<string> {
const job = await this.prisma.migrationJob.create({
data: {
organizationId,
type,
status: 'pending',
totalRecords: 0,
processedRecords: 0,
failedRecords: 0,
errors: [],
},
});
return job.id;
}

private async updateJobStatus(
jobId: string,
status: MigrationJob['status'],
): Promise<void> {
await this.prisma.migrationJob.update({
where: { id: jobId },
data: {
status,
...(status === 'processing' ? { startedAt: new Date() } : {}),
},
});
}

private async updateJob(jobId: string, data: Partial<MigrationJob>): Promise<void> {
await this.prisma.migrationJob.update({
where: { id: jobId },
data,
});
}

/**
* Get migration job status
*/
async getJobStatus(jobId: string): Promise<MigrationJob> {
const job = await this.prisma.migrationJob.findUnique({
where: { id: jobId },
});

if (!job) {
throw new Error(`Migration job ${jobId} not found`);
}

return job as MigrationJob;
}
}

H.1.5: Onboarding Analytics

Objective: Funnel analysis, drop-off detection, TTFV measurement

// src/customer-ops/onboarding/analytics.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';

export interface OnboardingMetrics {
totalSessions: number;
completedSessions: number;
averageTimeToValue: number;
completionRate: number;
dropOffPoints: DropOffAnalysis[];
cohortComparison: CohortMetrics[];
}

export interface DropOffAnalysis {
stepType: string;
stepTitle: string;
startedCount: number;
completedCount: number;
dropOffRate: number;
averageTimeOnStep: number;
}

export interface CohortMetrics {
cohortName: string;
period: string;
sessionCount: number;
completionRate: number;
averageTTFV: number;
sevenDayRetention: number;
}

@Injectable()
export class OnboardingAnalyticsService {
constructor(private prisma: PrismaService) {}

/**
* Calculate comprehensive onboarding metrics
*/
async getMetrics(startDate: Date, endDate: Date): Promise<OnboardingMetrics> {
const sessions = await this.prisma.onboardingSession.findMany({
where: {
startedAt: {
gte: startDate,
lte: endDate,
},
},
include: {
steps: true,
},
});

const completedSessions = sessions.filter(s => s.status === 'COMPLETED');

const totalTTFV = completedSessions.reduce((sum, session) => {
return sum + (session.completedAt.getTime() - session.startedAt.getTime());
}, 0);

const averageTimeToValue =
completedSessions.length > 0
? totalTTFV / completedSessions.length
: 0;

const completionRate =
sessions.length > 0
? completedSessions.length / sessions.length
: 0;

const dropOffPoints = await this.analyzeDropOffPoints(sessions);
const cohortComparison = await this.compareCohorts(startDate, endDate);

return {
totalSessions: sessions.length,
completedSessions: completedSessions.length,
averageTimeToValue,
completionRate,
dropOffPoints,
cohortComparison,
};
}

/**
* Identify steps where users drop off
*/
private async analyzeDropOffPoints(
sessions: any[],
): Promise<DropOffAnalysis[]> {
const stepAnalysis = new Map<string, DropOffAnalysis>();

for (const session of sessions) {
for (const step of session.steps) {
const key = `${step.stepType}-${step.title}`;

if (!stepAnalysis.has(key)) {
stepAnalysis.set(key, {
stepType: step.stepType,
stepTitle: step.title,
startedCount: 0,
completedCount: 0,
dropOffRate: 0,
averageTimeOnStep: 0,
});
}

const analysis = stepAnalysis.get(key)!;
analysis.startedCount++;

if (step.isCompleted) {
analysis.completedCount++;
}
}
}

// Calculate drop-off rates
return Array.from(stepAnalysis.values()).map(analysis => ({
...analysis,
dropOffRate:
analysis.startedCount > 0
? 1 - analysis.completedCount / analysis.startedCount
: 0,
}));
}

/**
* Compare onboarding metrics across weekly cohorts
*/
private async compareCohorts(
startDate: Date,
endDate: Date,
): Promise<CohortMetrics[]> {
const cohorts: CohortMetrics[] = [];
const weekMs = 7 * 24 * 60 * 60 * 1000;

let cohortStart = new Date(startDate);

while (cohortStart < endDate) {
const cohortEnd = new Date(Math.min(cohortStart.getTime() + weekMs, endDate.getTime()));

const sessions = await this.prisma.onboardingSession.findMany({
where: {
startedAt: {
gte: cohortStart,
lt: cohortEnd,
},
},
include: {
organization: {
include: {
users: true,
},
},
},
});

const completed = sessions.filter(s => s.status === 'COMPLETED');

const totalTTFV = completed.reduce((sum, s) => {
return sum + (s.completedAt.getTime() - s.startedAt.getTime());
}, 0);

// Calculate 7-day retention
const retentionDate = new Date(cohortEnd.getTime() + weekMs);
const activeAfterWeek = sessions.filter(s => {
// Check if organization had activity after 7 days
const lastActivity = s.organization.users.some(u =>
u.lastActivityAt && u.lastActivityAt > retentionDate
);
return lastActivity;
});

cohorts.push({
cohortName: `Week of ${cohortStart.toISOString().split('T')[0]}`,
period: `${cohortStart.toISOString().split('T')[0]} to ${cohortEnd.toISOString().split('T')[0]}`,
sessionCount: sessions.length,
completionRate: sessions.length > 0 ? completed.length / sessions.length : 0,
averageTTFV: completed.length > 0 ? totalTTFV / completed.length : 0,
sevenDayRetention:
sessions.length > 0 ? activeAfterWeek.length / sessions.length : 0,
});

cohortStart = cohortEnd;
}

return cohorts;
}
}

H.2: Support Ticketing System

H.2.1: Multi-Channel Support Intake

Objective: Email, in-app chat, phone, web form, API

// src/customer-ops/support/intake.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';

export interface SupportTicket {
id: string;
ticketNumber: string;
organizationId: string;
userId: string;
channel: 'email' | 'in_app_chat' | 'phone' | 'web_form' | 'api';
subject: string;
description: string;
priority: 'critical' | 'high' | 'medium' | 'low';
category: string;
status: 'open' | 'in_progress' | 'pending_customer' | 'resolved' | 'closed';
assignedTo?: string;
createdAt: Date;
updatedAt: Date;
resolvedAt?: Date;
firstResponseAt?: Date;
}

@Injectable()
export class SupportIntakeService {
private readonly logger = new Logger(SupportIntakeService.name);

constructor(
private prisma: PrismaService,
private eventEmitter: EventEmitter2,
) {}

/**
* Create ticket from email
*/
async createFromEmail(emailData: {
from: string;
subject: string;
body: string;
attachments?: any[];
}): Promise<SupportTicket> {
// Lookup user by email
const user = await this.prisma.user.findUnique({
where: { email: emailData.from },
include: { organization: true },
});

if (!user) {
throw new Error(`User not found for email: ${emailData.from}`);
}

return this.createTicket({
organizationId: user.organizationId,
userId: user.id,
channel: 'email',
subject: emailData.subject,
description: emailData.body,
priority: 'medium', // Will be auto-classified
});
}

/**
* Create ticket from in-app chat
*/
async createFromChat(chatData: {
userId: string;
conversationId: string;
messages: any[];
}): Promise<SupportTicket> {
const user = await this.prisma.user.findUnique({
where: { id: chatData.userId },
});

if (!user) {
throw new Error(`User not found: ${chatData.userId}`);
}

// Extract subject from first message
const firstMessage = chatData.messages[0];
const subject = firstMessage.content.substring(0, 100);

return this.createTicket({
organizationId: user.organizationId,
userId: chatData.userId,
channel: 'in_app_chat',
subject,
description: chatData.messages.map(m => m.content).join('\n\n'),
priority: 'medium',
metadata: {
conversationId: chatData.conversationId,
},
});
}

/**
* Create ticket from phone call
*/
async createFromPhone(phoneData: {
callerPhone: string;
agentId: string;
callTranscript: string;
callDuration: number;
}): Promise<SupportTicket> {
// Lookup user by phone
const user = await this.prisma.user.findFirst({
where: { phone: phoneData.callerPhone },
});

if (!user) {
throw new Error(`User not found for phone: ${phoneData.callerPhone}`);
}

return this.createTicket({
organizationId: user.organizationId,
userId: user.id,
channel: 'phone',
subject: 'Phone Support Request',
description: phoneData.callTranscript,
priority: 'high', // Phone calls are typically urgent
assignedTo: phoneData.agentId,
metadata: {
callDuration: phoneData.callDuration,
},
});
}

/**
* Create ticket from web form
*/
async createFromWebForm(formData: {
userId: string;
subject: string;
description: string;
category: string;
priority?: 'critical' | 'high' | 'medium' | 'low';
}): Promise<SupportTicket> {
const user = await this.prisma.user.findUnique({
where: { id: formData.userId },
});

if (!user) {
throw new Error(`User not found: ${formData.userId}`);
}

return this.createTicket({
organizationId: user.organizationId,
userId: formData.userId,
channel: 'web_form',
subject: formData.subject,
description: formData.description,
category: formData.category,
priority: formData.priority || 'medium',
});
}

/**
* Create ticket from API
*/
async createFromAPI(apiData: {
organizationId: string;
userId: string;
subject: string;
description: string;
priority: 'critical' | 'high' | 'medium' | 'low';
category?: string;
}): Promise<SupportTicket> {
return this.createTicket({
organizationId: apiData.organizationId,
userId: apiData.userId,
channel: 'api',
subject: apiData.subject,
description: apiData.description,
priority: apiData.priority,
category: apiData.category,
});
}

/**
* Core ticket creation logic
*/
private async createTicket(data: {
organizationId: string;
userId: string;
channel: SupportTicket['channel'];
subject: string;
description: string;
priority: SupportTicket['priority'];
category?: string;
assignedTo?: string;
metadata?: any;
}): Promise<SupportTicket> {
// Generate ticket number
const ticketNumber = await this.generateTicketNumber();

const ticket = await this.prisma.supportTicket.create({
data: {
ticketNumber,
organizationId: data.organizationId,
userId: data.userId,
channel: data.channel,
subject: data.subject,
description: data.description,
priority: data.priority,
category: data.category || 'GENERAL',
status: 'open',
assignedTo: data.assignedTo,
metadata: data.metadata,
},
});

// Emit event for async processing (classification, routing)
this.eventEmitter.emit('support.ticket.created', ticket);

this.logger.log(`Created ticket ${ticketNumber} from ${data.channel}`);

return ticket as SupportTicket;
}

private async generateTicketNumber(): Promise<string> {
const count = await this.prisma.supportTicket.count();
const number = (count + 1).toString().padStart(6, '0');
return `TKT-${number}`;
}
}

H.2.2: SLA Tracking

Objective: Tier-based SLAs with real-time monitoring

// src/customer-ops/support/sla.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { Cron, CronExpression } from '@nestjs/schedule';

export interface SLAConfiguration {
priority: 'critical' | 'high' | 'medium' | 'low';
firstResponseMinutes: number;
resolutionHours: number;
}

export interface SLAStatus {
ticketId: string;
priority: string;
responseDeadline: Date;
resolutionDeadline: Date;
isResponseBreached: boolean;
isResolutionBreached: boolean;
minutesUntilResponseBreach: number;
hoursUntilResolutionBreach: number;
}

@Injectable()
export class SLAService {
private readonly logger = new Logger(SLAService.name);

private readonly SLA_CONFIG: SLAConfiguration[] = [
{ priority: 'critical', firstResponseMinutes: 15, resolutionHours: 4 },
{ priority: 'high', firstResponseMinutes: 60, resolutionHours: 8 },
{ priority: 'medium', firstResponseMinutes: 240, resolutionHours: 24 },
{ priority: 'low', firstResponseMinutes: 480, resolutionHours: 48 },
];

constructor(private prisma: PrismaService) {}

/**
* Calculate SLA status for a ticket
*/
async getSLAStatus(ticketId: string): Promise<SLAStatus> {
const ticket = await this.prisma.supportTicket.findUnique({
where: { id: ticketId },
});

if (!ticket) {
throw new Error(`Ticket ${ticketId} not found`);
}

const slaConfig = this.SLA_CONFIG.find(c => c.priority === ticket.priority);
if (!slaConfig) {
throw new Error(`No SLA config for priority: ${ticket.priority}`);
}

const responseDeadline = new Date(
ticket.createdAt.getTime() + slaConfig.firstResponseMinutes * 60 * 1000
);

const resolutionDeadline = new Date(
ticket.createdAt.getTime() + slaConfig.resolutionHours * 60 * 60 * 1000
);

const now = new Date();

const isResponseBreached = !ticket.firstResponseAt && now > responseDeadline;
const isResolutionBreached = !ticket.resolvedAt && now > resolutionDeadline;

const minutesUntilResponseBreach = ticket.firstResponseAt
? 0
: Math.max(0, Math.floor((responseDeadline.getTime() - now.getTime()) / 60000));

const hoursUntilResolutionBreach = ticket.resolvedAt
? 0
: Math.max(0, Math.floor((resolutionDeadline.getTime() - now.getTime()) / 3600000));

return {
ticketId,
priority: ticket.priority,
responseDeadline,
resolutionDeadline,
isResponseBreached,
isResolutionBreached,
minutesUntilResponseBreach,
hoursUntilResolutionBreach,
};
}

/**
* Get all tickets at risk of SLA breach
*/
async getAtRiskTickets(): Promise<SLAStatus[]> {
const openTickets = await this.prisma.supportTicket.findMany({
where: {
status: {
in: ['open', 'in_progress'],
},
},
});

const statuses = await Promise.all(
openTickets.map(t => this.getSLAStatus(t.id))
);

// Filter to tickets within 25% of breach
return statuses.filter(s => {
if (!s.isResponseBreached && s.minutesUntilResponseBreach > 0) {
const slaConfig = this.SLA_CONFIG.find(c => c.priority === s.priority);
const percentRemaining =
s.minutesUntilResponseBreach / slaConfig!.firstResponseMinutes;
if (percentRemaining < 0.25) return true;
}

if (!s.isResolutionBreached && s.hoursUntilResolutionBreach > 0) {
const slaConfig = this.SLA_CONFIG.find(c => c.priority === s.priority);
const percentRemaining =
s.hoursUntilResolutionBreach / slaConfig!.resolutionHours;
if (percentRemaining < 0.25) return true;
}

return false;
});
}

/**
* Cron job to check SLA compliance every 5 minutes
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async monitorSLACompliance(): Promise<void> {
this.logger.log('Running SLA compliance check');

const atRisk = await this.getAtRiskTickets();

if (atRisk.length > 0) {
this.logger.warn(`${atRisk.length} tickets at risk of SLA breach`);

// TODO: Send alerts to support team
}

// Check for breached tickets
const breached = atRisk.filter(
s => s.isResponseBreached || s.isResolutionBreached
);

if (breached.length > 0) {
this.logger.error(`${breached.length} tickets have breached SLA`);

// TODO: Escalate to management
}
}
}

H.2.3: AI-Powered Ticket Classification

Objective: Auto-categorization, priority prediction, routing

// src/customer-ops/support/classification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { AnthropicService } from '@/ai/anthropic.service';
import { OnEvent } from '@nestjs/event-emitter';

export interface TicketClassification {
category: string;
subcategory?: string;
suggestedPriority: 'critical' | 'high' | 'medium' | 'low';
confidence: number;
tags: string[];
suggestedAssignee?: string;
reasoning: string;
}

@Injectable()
export class ClassificationService {
private readonly logger = new Logger(ClassificationService.name);

constructor(
private prisma: PrismaService,
private anthropic: AnthropicService,
) {}

/**
* Auto-classify ticket on creation
*/
@OnEvent('support.ticket.created')
async handleTicketCreated(ticket: any): Promise<void> {
try {
const classification = await this.classifyTicket(ticket);

await this.prisma.supportTicket.update({
where: { id: ticket.id },
data: {
category: classification.category,
subcategory: classification.subcategory,
priority: classification.suggestedPriority,
tags: classification.tags,
assignedTo: classification.suggestedAssignee,
metadata: {
...ticket.metadata,
aiClassification: {
...classification,
classifiedAt: new Date(),
},
},
},
});

this.logger.log(
`Classified ticket ${ticket.ticketNumber} as ${classification.category} (${classification.confidence})`
);
} catch (error) {
this.logger.error(`Failed to classify ticket: ${error.message}`, error.stack);
}
}

/**
* Classify ticket using Claude
*/
private async classifyTicket(ticket: any): Promise<TicketClassification> {
const prompt = `
You are a support ticket classification system for a regulated SaaS QMS platform used by pharmaceutical, biotech, and medical device companies.

Analyze this support ticket and provide classification:

**Ticket Details:**
- Subject: ${ticket.subject}
- Description: ${ticket.description}
- Channel: ${ticket.channel}

**Categories:**
- TECHNICAL_ISSUE (system errors, bugs, performance)
- FEATURE_REQUEST (new features, enhancements)
- COMPLIANCE_QUESTION (regulatory, validation, audit)
- USER_MANAGEMENT (access, permissions, SSO)
- DATA_MIGRATION (import, export, integration)
- TRAINING_SUPPORT (how-to, best practices)
- BILLING_ACCOUNT (subscriptions, invoices, payments)
- SECURITY_INCIDENT (data breach, unauthorized access)

**Priority Guidelines:**
- CRITICAL: System down, data loss, security incident, regulatory non-compliance
- HIGH: Major functionality broken, blocking work, compliance at risk
- MEDIUM: Minor functionality issue, workaround available
- LOW: Cosmetic issue, feature request, general question

Respond with JSON:
{
"category": "string",
"subcategory": "string (optional)",
"suggestedPriority": "critical|high|medium|low",
"confidence": 0.0-1.0,
"tags": ["tag1", "tag2"],
"reasoning": "brief explanation"
}
`;

const response = await this.anthropic.generateCompletion({
model: 'claude-sonnet-4',
prompt,
maxTokens: 500,
});

const classification = JSON.parse(response.content);

// Route to appropriate specialist
classification.suggestedAssignee = await this.suggestAssignee(
classification.category
);

return classification;
}

/**
* Suggest assignee based on category and team availability
*/
private async suggestAssignee(category: string): Promise<string | undefined> {
const specialistMap: Record<string, string> = {
TECHNICAL_ISSUE: 'technical-support-queue',
COMPLIANCE_QUESTION: 'compliance-specialist-queue',
SECURITY_INCIDENT: 'security-team-queue',
DATA_MIGRATION: 'data-services-queue',
BILLING_ACCOUNT: 'billing-team-queue',
};

return specialistMap[category];
}
}

Objective: Self-service articles, video tutorials, FAQ

// src/customer-ops/support/knowledge-base.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { VectorSearchService } from '@/ai/vector-search.service';

export interface KnowledgeArticle {
id: string;
title: string;
slug: string;
content: string;
category: string;
tags: string[];
viewCount: number;
helpfulCount: number;
notHelpfulCount: number;
videoUrl?: string;
relatedArticles: string[];
createdAt: Date;
updatedAt: Date;
}

@Injectable()
export class KnowledgeBaseService {
constructor(
private prisma: PrismaService,
private vectorSearch: VectorSearchService,
) {}

/**
* Semantic search across knowledge base
*/
async search(query: string, limit = 10): Promise<KnowledgeArticle[]> {
// Use vector search for semantic matching
const results = await this.vectorSearch.search({
index: 'knowledge-base',
query,
limit,
threshold: 0.7,
});

const articleIds = results.map(r => r.id);

const articles = await this.prisma.knowledgeArticle.findMany({
where: {
id: {
in: articleIds,
},
},
});

// Sort by vector search score
return articles.sort((a, b) => {
const scoreA = results.find(r => r.id === a.id)?.score || 0;
const scoreB = results.find(r => r.id === b.id)?.score || 0;
return scoreB - scoreA;
});
}

/**
* Get related articles based on current article
*/
async getRelatedArticles(articleId: string): Promise<KnowledgeArticle[]> {
const article = await this.prisma.knowledgeArticle.findUnique({
where: { id: articleId },
});

if (!article) {
throw new Error(`Article ${articleId} not found`);
}

// Use tags and category to find related
const related = await this.prisma.knowledgeArticle.findMany({
where: {
OR: [
{ category: article.category },
{
tags: {
hasSome: article.tags,
},
},
],
id: {
not: articleId,
},
},
take: 5,
orderBy: {
viewCount: 'desc',
},
});

return related as KnowledgeArticle[];
}

/**
* Suggest articles for a support ticket
*/
async suggestForTicket(ticketId: string): Promise<KnowledgeArticle[]> {
const ticket = await this.prisma.supportTicket.findUnique({
where: { id: ticketId },
});

if (!ticket) {
throw new Error(`Ticket ${ticketId} not found`);
}

// Search using ticket subject + description
const searchQuery = `${ticket.subject} ${ticket.description}`;

return this.search(searchQuery, 5);
}

/**
* Track article helpfulness
*/
async recordFeedback(
articleId: string,
helpful: boolean,
): Promise<void> {
await this.prisma.knowledgeArticle.update({
where: { id: articleId },
data: {
[helpful ? 'helpfulCount' : 'notHelpfulCount']: {
increment: 1,
},
},
});
}
}

H.2.5: Support Analytics Dashboard

Objective: CSAT, response time, resolution time, agent performance

// src/customer-ops/support/analytics.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';

export interface SupportMetrics {
period: string;
totalTickets: number;
resolvedTickets: number;
averageFirstResponseTime: number;
averageResolutionTime: number;
csat: number;
slaCompliance: number;
ticketsByPriority: Record<string, number>;
ticketsByCategory: Record<string, number>;
agentPerformance: AgentMetrics[];
}

export interface AgentMetrics {
agentId: string;
agentName: string;
ticketsHandled: number;
ticketsResolved: number;
averageResponseTime: number;
averageResolutionTime: number;
csat: number;
slaCompliance: number;
}

@Injectable()
export class SupportAnalyticsService {
constructor(private prisma: PrismaService) {}

/**
* Calculate comprehensive support metrics
*/
async getMetrics(startDate: Date, endDate: Date): Promise<SupportMetrics> {
const tickets = await this.prisma.supportTicket.findMany({
where: {
createdAt: {
gte: startDate,
lte: endDate,
},
},
include: {
responses: true,
satisfaction: true,
},
});

const resolvedTickets = tickets.filter(t => t.status === 'resolved' || t.status === 'closed');

// Calculate average first response time
const responseTimes = tickets
.filter(t => t.firstResponseAt)
.map(t => t.firstResponseAt!.getTime() - t.createdAt.getTime());

const averageFirstResponseTime =
responseTimes.length > 0
? responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
: 0;

// Calculate average resolution time
const resolutionTimes = resolvedTickets
.filter(t => t.resolvedAt)
.map(t => t.resolvedAt!.getTime() - t.createdAt.getTime());

const averageResolutionTime =
resolutionTimes.length > 0
? resolutionTimes.reduce((sum, time) => sum + time, 0) / resolutionTimes.length
: 0;

// Calculate CSAT
const satisfactionScores = tickets
.filter(t => t.satisfaction?.score)
.map(t => t.satisfaction!.score);

const csat =
satisfactionScores.length > 0
? satisfactionScores.reduce((sum, score) => sum + score, 0) /
satisfactionScores.length
: 0;

// Calculate SLA compliance
const slaMetTickets = tickets.filter(async t => {
const slaStatus = await this.getSLAStatus(t.id);
return !slaStatus.isResponseBreached && !slaStatus.isResolutionBreached;
});

const slaCompliance =
tickets.length > 0 ? slaMetTickets.length / tickets.length : 0;

// Group by priority
const ticketsByPriority = tickets.reduce(
(acc, t) => {
acc[t.priority] = (acc[t.priority] || 0) + 1;
return acc;
},
{} as Record<string, number>
);

// Group by category
const ticketsByCategory = tickets.reduce(
(acc, t) => {
acc[t.category] = (acc[t.category] || 0) + 1;
return acc;
},
{} as Record<string, number>
);

const agentPerformance = await this.getAgentPerformance(startDate, endDate);

return {
period: `${startDate.toISOString()} to ${endDate.toISOString()}`,
totalTickets: tickets.length,
resolvedTickets: resolvedTickets.length,
averageFirstResponseTime,
averageResolutionTime,
csat,
slaCompliance,
ticketsByPriority,
ticketsByCategory,
agentPerformance,
};
}

private async getSLAStatus(ticketId: string): Promise<any> {
// Implementation from H.2.2
throw new Error('Not implemented');
}

private async getAgentPerformance(
startDate: Date,
endDate: Date,
): Promise<AgentMetrics[]> {
const agents = await this.prisma.user.findMany({
where: {
roles: {
some: {
role: {
in: ['SUPPORT_AGENT', 'SUPPORT_MANAGER'],
},
},
},
},
include: {
assignedTickets: {
where: {
createdAt: {
gte: startDate,
lte: endDate,
},
},
include: {
satisfaction: true,
},
},
},
});

return agents.map(agent => {
const tickets = agent.assignedTickets;
const resolved = tickets.filter(t => t.status === 'resolved' || t.status === 'closed');

const responseTimes = tickets
.filter(t => t.firstResponseAt)
.map(t => t.firstResponseAt!.getTime() - t.createdAt.getTime());

const resolutionTimes = resolved
.filter(t => t.resolvedAt)
.map(t => t.resolvedAt!.getTime() - t.createdAt.getTime());

const satisfactionScores = tickets
.filter(t => t.satisfaction?.score)
.map(t => t.satisfaction!.score);

return {
agentId: agent.id,
agentName: `${agent.firstName} ${agent.lastName}`,
ticketsHandled: tickets.length,
ticketsResolved: resolved.length,
averageResponseTime:
responseTimes.length > 0
? responseTimes.reduce((sum, t) => sum + t, 0) / responseTimes.length
: 0,
averageResolutionTime:
resolutionTimes.length > 0
? resolutionTimes.reduce((sum, t) => sum + t, 0) / resolutionTimes.length
: 0,
csat:
satisfactionScores.length > 0
? satisfactionScores.reduce((sum, s) => sum + s, 0) / satisfactionScores.length
: 0,
slaCompliance: 0, // TODO: Calculate per agent
};
});
}
}

H.3: Customer Success & Health Scoring

H.3.1: Customer Health Score Model

Objective: Multi-dimensional health scoring

// src/customer-ops/customer-success/health-score.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';

export interface HealthScore {
organizationId: string;
overallScore: number; // 0-100
grade: 'A' | 'B' | 'C' | 'D' | 'F';
trend: 'improving' | 'stable' | 'declining';
dimensions: {
usageDepth: number; // 0-100
adoptionBreadth: number;
supportSentiment: number;
paymentReliability: number;
engagementRecency: number;
};
riskFactors: RiskFactor[];
calculatedAt: Date;
}

export interface RiskFactor {
type: string;
severity: 'critical' | 'high' | 'medium' | 'low';
description: string;
impact: number; // -100 to 0
}

@Injectable()
export class HealthScoreService {
private readonly logger = new Logger(HealthScoreService.name);

constructor(private prisma: PrismaService) {}

/**
* Calculate comprehensive health score
*/
async calculateHealthScore(organizationId: string): Promise<HealthScore> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: {
users: {
include: {
activities: {
where: {
createdAt: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days
},
},
},
},
},
documents: true,
supportTickets: {
include: {
satisfaction: true,
},
},
subscriptions: {
include: {
invoices: true,
},
},
},
});

if (!org) {
throw new Error(`Organization ${organizationId} not found`);
}

// Calculate dimension scores
const usageDepth = await this.calculateUsageDepth(org);
const adoptionBreadth = await this.calculateAdoptionBreadth(org);
const supportSentiment = await this.calculateSupportSentiment(org);
const paymentReliability = await this.calculatePaymentReliability(org);
const engagementRecency = await this.calculateEngagementRecency(org);

// Weighted average
const weights = {
usageDepth: 0.3,
adoptionBreadth: 0.25,
supportSentiment: 0.2,
paymentReliability: 0.15,
engagementRecency: 0.1,
};

const overallScore =
usageDepth * weights.usageDepth +
adoptionBreadth * weights.adoptionBreadth +
supportSentiment * weights.supportSentiment +
paymentReliability * weights.paymentReliability +
engagementRecency * weights.engagementRecency;

const grade = this.scoreToGrade(overallScore);

// Detect risk factors
const riskFactors = await this.detectRiskFactors(org);

// Calculate trend
const previousScore = await this.getPreviousScore(organizationId);
const trend = previousScore
? overallScore > previousScore + 5
? 'improving'
: overallScore < previousScore - 5
? 'declining'
: 'stable'
: 'stable';

const healthScore: HealthScore = {
organizationId,
overallScore,
grade,
trend,
dimensions: {
usageDepth,
adoptionBreadth,
supportSentiment,
paymentReliability,
engagementRecency,
},
riskFactors,
calculatedAt: new Date(),
};

// Store in database
await this.saveHealthScore(healthScore);

return healthScore;
}

/**
* Usage Depth: Feature adoption and usage intensity
*/
private async calculateUsageDepth(org: any): Promise<number> {
const totalFeatures = 50; // Total available features
const activities = org.users.flatMap((u: any) => u.activities);

const uniqueFeaturesUsed = new Set(
activities.map((a: any) => a.featureUsed)
).size;

const featureAdoptionScore = (uniqueFeaturesUsed / totalFeatures) * 100;

// Usage frequency
const avgActivitiesPerUser =
org.users.length > 0
? activities.length / org.users.length
: 0;

const frequencyScore = Math.min(100, avgActivitiesPerUser * 5);

return (featureAdoptionScore * 0.6 + frequencyScore * 0.4);
}

/**
* Adoption Breadth: Number of active users / total licensed users
*/
private async calculateAdoptionBreadth(org: any): Promise<number> {
const totalUsers = org.users.length;
const activeUsers = org.users.filter((u: any) => {
const lastActivity = u.activities[0]?.createdAt;
if (!lastActivity) return false;

const daysSinceLastActivity =
(Date.now() - lastActivity.getTime()) / (24 * 60 * 60 * 1000);

return daysSinceLastActivity <= 7; // Active in last 7 days
}).length;

if (totalUsers === 0) return 0;

return (activeUsers / totalUsers) * 100;
}

/**
* Support Sentiment: CSAT scores from support tickets
*/
private async calculateSupportSentiment(org: any): Promise<number> {
const recentTickets = org.supportTickets.filter((t: any) => {
const daysSinceCreated =
(Date.now() - t.createdAt.getTime()) / (24 * 60 * 60 * 1000);
return daysSinceCreated <= 90; // Last 90 days
});

if (recentTickets.length === 0) return 75; // Neutral if no tickets

const satisfactionScores = recentTickets
.filter((t: any) => t.satisfaction?.score)
.map((t: any) => t.satisfaction.score);

if (satisfactionScores.length === 0) return 75;

const avgSatisfaction =
satisfactionScores.reduce((sum: number, s: number) => sum + s, 0) /
satisfactionScores.length;

return avgSatisfaction; // Already 0-100
}

/**
* Payment Reliability: On-time payments, failed payments
*/
private async calculatePaymentReliability(org: any): Promise<number> {
const subscription = org.subscriptions[0];
if (!subscription) return 50; // Neutral if no subscription

const recentInvoices = subscription.invoices.filter((inv: any) => {
const daysSinceCreated =
(Date.now() - inv.createdAt.getTime()) / (24 * 60 * 60 * 1000);
return daysSinceCreated <= 365; // Last year
});

if (recentInvoices.length === 0) return 100;

const paidOnTime = recentInvoices.filter((inv: any) => {
if (inv.status !== 'paid') return false;
return inv.paidAt <= inv.dueDate;
}).length;

return (paidOnTime / recentInvoices.length) * 100;
}

/**
* Engagement Recency: Days since last login/activity
*/
private async calculateEngagementRecency(org: any): Promise<number> {
const allActivities = org.users.flatMap((u: any) => u.activities);

if (allActivities.length === 0) return 0;

const mostRecentActivity = allActivities.reduce((latest: any, current: any) => {
return current.createdAt > latest.createdAt ? current : latest;
});

const daysSinceLastActivity =
(Date.now() - mostRecentActivity.createdAt.getTime()) / (24 * 60 * 60 * 1000);

// Score decreases as days increase
if (daysSinceLastActivity <= 1) return 100;
if (daysSinceLastActivity <= 3) return 90;
if (daysSinceLastActivity <= 7) return 75;
if (daysSinceLastActivity <= 14) return 50;
if (daysSinceLastActivity <= 30) return 25;
return 0;
}

private scoreToGrade(score: number): 'A' | 'B' | 'C' | 'D' | 'F' {
if (score >= 90) return 'A';
if (score >= 75) return 'B';
if (score >= 60) return 'C';
if (score >= 50) return 'D';
return 'F';
}

/**
* Detect risk factors that could lead to churn
*/
private async detectRiskFactors(org: any): Promise<RiskFactor[]> {
const risks: RiskFactor[] = [];

// Low usage depth
const usageDepth = await this.calculateUsageDepth(org);
if (usageDepth < 30) {
risks.push({
type: 'LOW_USAGE',
severity: 'high',
description: 'Low feature adoption and usage frequency',
impact: -15,
});
}

// Low adoption breadth
const adoptionBreadth = await this.calculateAdoptionBreadth(org);
if (adoptionBreadth < 40) {
risks.push({
type: 'LOW_ADOPTION',
severity: 'high',
description: 'Less than 40% of users are active',
impact: -20,
});
}

// Poor support sentiment
const supportSentiment = await this.calculateSupportSentiment(org);
if (supportSentiment < 60) {
risks.push({
type: 'POOR_SUPPORT_EXPERIENCE',
severity: 'critical',
description: 'Low CSAT scores on support tickets',
impact: -25,
});
}

// Payment issues
const paymentReliability = await this.calculatePaymentReliability(org);
if (paymentReliability < 80) {
risks.push({
type: 'PAYMENT_ISSUES',
severity: 'critical',
description: 'Late or failed payments',
impact: -30,
});
}

// Engagement decline
const engagementRecency = await this.calculateEngagementRecency(org);
if (engagementRecency < 25) {
risks.push({
type: 'DISENGAGEMENT',
severity: 'high',
description: 'No activity in over 14 days',
impact: -15,
});
}

// No champion
const hasChampion = org.users.some((u: any) => {
return u.activities.length > 50; // Power user
});
if (!hasChampion) {
risks.push({
type: 'NO_CHAMPION',
severity: 'medium',
description: 'No power user or internal champion',
impact: -10,
});
}

return risks;
}

private async getPreviousScore(organizationId: string): Promise<number | null> {
const previous = await this.prisma.healthScoreHistory.findFirst({
where: { organizationId },
orderBy: { calculatedAt: 'desc' },
skip: 1, // Skip most recent (current)
});

return previous?.overallScore || null;
}

private async saveHealthScore(healthScore: HealthScore): Promise<void> {
await this.prisma.healthScoreHistory.create({
data: {
organizationId: healthScore.organizationId,
overallScore: healthScore.overallScore,
grade: healthScore.grade,
trend: healthScore.trend,
dimensions: healthScore.dimensions,
riskFactors: healthScore.riskFactors,
calculatedAt: healthScore.calculatedAt,
},
});
}
}

Due to output length constraints, I'll continue the document in the next response. The document is approximately 40% complete. Shall I continue with the remaining sections?