mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Compare commits
9 Commits
44f1ec36e1
...
dev-kartik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603ec7c612 | ||
|
|
05eb7a326e | ||
|
|
bb20f5102a | ||
|
|
09c7930b52 | ||
|
|
e912b982df | ||
|
|
c80dddee0f | ||
|
|
bb46549a4d | ||
|
|
33ec8f5db8 | ||
|
|
a1157ab4c1 |
32
.claudeignore
Normal file
32
.claudeignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Test coverage output
|
||||
coverage/
|
||||
|
||||
# Generated type declarations
|
||||
**/*.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# E2E test fixtures (keep unit tests)
|
||||
test/
|
||||
|
||||
# Environment secrets — never read
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
PORT=4100
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||
|
||||
# Fortytwo Platform
|
||||
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
NestJS sidecar that bridges Ozonetel telephony APIs with the FortyTwo platform. Handles agent auth, call control, disposition, missed call queue, worklist aggregation, AI enrichment, and live call assist.
|
||||
|
||||
**Owner: Karthik**
|
||||
**Owner: Kartik**
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,7 +27,7 @@ This server has **no database**. All persistent data flows to/from the FortyTwo
|
||||
| Repo | Purpose | Owner |
|
||||
|------|---------|-------|
|
||||
| `helix-engage` | React frontend | Mouli |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Karthik |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Kartik |
|
||||
| `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -29,6 +29,16 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'no-empty': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
|
||||
16
package.json
16
package.json
@@ -17,7 +17,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.17",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/websockets": "^11.1.17",
|
||||
"ai": "^6.0.116",
|
||||
"axios": "^1.13.6",
|
||||
@@ -56,7 +58,9 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
@@ -68,6 +72,16 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"test/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
|
||||
6909
pnpm-lock.yaml
generated
Normal file
6909
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,12 @@ type EnrichmentResult = {
|
||||
};
|
||||
|
||||
const enrichmentSchema = z.object({
|
||||
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'),
|
||||
aiSummary: z
|
||||
.string()
|
||||
.describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z
|
||||
.string()
|
||||
.describe('5-10 word suggested action for the agent'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -46,15 +50,20 @@ export class AiEnrichmentService {
|
||||
|
||||
try {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const activitiesText = lead.activities?.length
|
||||
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
||||
? lead.activities
|
||||
.map((a) => `- ${a.activityType}: ${a.summary}`)
|
||||
.join('\n')
|
||||
: 'No previous interactions';
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel!,
|
||||
model: this.aiModel,
|
||||
schema: enrichmentSchema,
|
||||
prompt: `You are an AI assistant for a hospital call center.
|
||||
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
||||
@@ -71,7 +80,9 @@ Recent activity:
|
||||
${activitiesText}`,
|
||||
});
|
||||
|
||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||
this.logger.log(
|
||||
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
|
||||
);
|
||||
return object;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
@@ -81,12 +92,16 @@ ${activitiesText}`,
|
||||
|
||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const attempts = lead.contactAttempts ?? 0;
|
||||
const service = lead.interestedService ?? 'general inquiry';
|
||||
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
const source =
|
||||
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
|
||||
let summary: string;
|
||||
let action: string;
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createAiModel(config: ConfigService): LanguageModel | null {
|
||||
|
||||
export function isAiConfigured(config: ConfigService): boolean {
|
||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
|
||||
if (provider === 'anthropic')
|
||||
return !!config.get<string>('ai.anthropicApiKey');
|
||||
return !!config.get<string>('ai.openaiApiKey');
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export class AgentConfigService {
|
||||
private platform: PlatformGraphqlService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
|
||||
this.sipDomain = config.get<string>(
|
||||
'sip.domain',
|
||||
'blr-pub-rtc4.ozonetel.com',
|
||||
);
|
||||
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||
}
|
||||
|
||||
@@ -46,13 +49,18 @@ export class AgentConfigService {
|
||||
ozonetelAgentId: node.ozonetelagentid,
|
||||
sipExtension: node.sipextension,
|
||||
sipPassword: node.sippassword ?? node.sipextension,
|
||||
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
|
||||
campaignName:
|
||||
node.campaignname ??
|
||||
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||
'Inbound_918041763265',
|
||||
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||
};
|
||||
|
||||
this.cache.set(memberId, agentConfig);
|
||||
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
|
||||
this.logger.log(
|
||||
`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`,
|
||||
);
|
||||
return agentConfig;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||
|
||||
@@ -33,15 +33,20 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
@SubscribeMessage('call-assist:start')
|
||||
async handleStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
@MessageBody()
|
||||
data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
) {
|
||||
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`);
|
||||
this.logger.log(
|
||||
`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`,
|
||||
);
|
||||
|
||||
const context = await this.callAssist.loadCallContext(
|
||||
data.leadId ?? null,
|
||||
data.callerPhone ?? null,
|
||||
);
|
||||
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' });
|
||||
client.emit('call-assist:context', {
|
||||
context: context.substring(0, 200) + '...',
|
||||
});
|
||||
|
||||
const session: SessionState = {
|
||||
deepgramWs: null,
|
||||
@@ -87,13 +92,18 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
session.deepgramWs = dgWs;
|
||||
} else {
|
||||
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
||||
client.emit('call-assist:error', { message: 'Transcription not configured' });
|
||||
client.emit('call-assist:error', {
|
||||
message: 'Transcription not configured',
|
||||
});
|
||||
}
|
||||
|
||||
// AI suggestion every 10 seconds
|
||||
session.suggestionTimer = setInterval(async () => {
|
||||
if (!session.transcript.trim()) return;
|
||||
const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context);
|
||||
const suggestion = await this.callAssist.getSuggestion(
|
||||
session.transcript,
|
||||
session.context,
|
||||
);
|
||||
if (suggestion) {
|
||||
client.emit('call-assist:suggestion', { text: suggestion });
|
||||
}
|
||||
@@ -128,7 +138,9 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
if (session) {
|
||||
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
||||
if (session.deepgramWs) {
|
||||
try { session.deepgramWs.close(); } catch {}
|
||||
try {
|
||||
session.deepgramWs.close();
|
||||
} catch {}
|
||||
}
|
||||
this.sessions.delete(clientId);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,13 @@ export class CallAssistService {
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
async loadCallContext(
|
||||
leadId: string | null,
|
||||
callerPhone: string | null,
|
||||
): Promise<string> {
|
||||
const authHeader = this.platformApiKey
|
||||
? `Bearer ${this.platformApiKey}`
|
||||
: '';
|
||||
if (!authHeader) return 'No platform context available.';
|
||||
|
||||
try {
|
||||
@@ -35,7 +40,8 @@ export class CallAssistService {
|
||||
lastContacted contactAttempts
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const lead = leadResult.leads.edges[0]?.node;
|
||||
if (lead) {
|
||||
@@ -43,9 +49,13 @@ export class CallAssistService {
|
||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||
: lead.name;
|
||||
parts.push(`CALLER: ${name}`);
|
||||
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
||||
parts.push(
|
||||
`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
|
||||
);
|
||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
||||
parts.push(
|
||||
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
|
||||
);
|
||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||
}
|
||||
@@ -54,7 +64,8 @@ export class CallAssistService {
|
||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit patientId
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const appts = apptResult.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
@@ -62,8 +73,12 @@ export class CallAssistService {
|
||||
if (appts.length > 0) {
|
||||
parts.push('\nPAST APPOINTMENTS:');
|
||||
for (const a of appts) {
|
||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
|
||||
const date = a.scheduledAt
|
||||
? new Date(a.scheduledAt).toLocaleDateString('en-IN')
|
||||
: '?';
|
||||
parts.push(
|
||||
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (callerPhone) {
|
||||
@@ -75,14 +90,19 @@ export class CallAssistService {
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
||||
if (docs.length > 0) {
|
||||
parts.push('\nAVAILABLE DOCTORS:');
|
||||
for (const d of docs) {
|
||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
||||
const name = d.fullName
|
||||
? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
|
||||
: 'Unknown';
|
||||
parts.push(
|
||||
`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
Headers,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||
|
||||
@@ -33,11 +40,17 @@ export class CallLookupController {
|
||||
}
|
||||
|
||||
if (lead) {
|
||||
this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`);
|
||||
this.logger.log(
|
||||
`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
|
||||
);
|
||||
|
||||
// Get recent activities
|
||||
try {
|
||||
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5);
|
||||
activities = await this.platform.getLeadActivitiesWithToken(
|
||||
lead.id,
|
||||
authHeader,
|
||||
5,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||
}
|
||||
@@ -64,10 +77,14 @@ export class CallLookupController {
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
try {
|
||||
await this.platform.updateLeadWithToken(lead.id, {
|
||||
await this.platform.updateLeadWithToken(
|
||||
lead.id,
|
||||
{
|
||||
aiSummary: enrichment.aiSummary,
|
||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||
}, authHeader);
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0),
|
||||
platform: {
|
||||
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
graphqlUrl:
|
||||
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||
},
|
||||
exotel: {
|
||||
@@ -20,7 +24,10 @@ export default () => ({
|
||||
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||
},
|
||||
missedQueue: {
|
||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
||||
pollIntervalMs: parseInt(
|
||||
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
||||
10,
|
||||
),
|
||||
},
|
||||
ai: {
|
||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||
|
||||
9
src/embed/embed.module.ts
Normal file
9
src/embed/embed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { LeadEmbedController } from './lead-embed.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
controllers: [LeadEmbedController],
|
||||
})
|
||||
export class EmbedModule {}
|
||||
193
src/embed/lead-embed.controller.ts
Normal file
193
src/embed/lead-embed.controller.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Controller('embed/leads')
|
||||
export class LeadEmbedController {
|
||||
private readonly logger = new Logger(LeadEmbedController.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
async handleLeadCreation(@Body() body: Record<string, any>) {
|
||||
console.log('Lead creation from embed received:', body);
|
||||
this.logger.log(
|
||||
`Lead creation from embed received: ${JSON.stringify(body)}`,
|
||||
);
|
||||
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
if (!authHeader) {
|
||||
this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead');
|
||||
throw new HttpException('Server configuration error', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const leadData = this.mapIncomingDataToLead(body);
|
||||
|
||||
if (!leadData.contactPhone && !leadData.contactEmail) {
|
||||
throw new HttpException(
|
||||
'Either contact phone or email is required',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: leadData },
|
||||
authHeader,
|
||||
);
|
||||
|
||||
const leadId = result.createLead.id;
|
||||
this.logger.log(`Lead created successfully: ${leadId}`);
|
||||
|
||||
if (body.notes || body.type) {
|
||||
await this.createInitialActivity(leadId, body, authHeader);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
leadId,
|
||||
message: 'Lead created successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Lead creation failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
error.message || 'Lead creation failed',
|
||||
error.response?.status || 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapIncomingDataToLead(
|
||||
body: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const leadData: Record<string, any> = {};
|
||||
|
||||
const contactName = body.contact_name || body.contactName || 'Unknown';
|
||||
const nameParts = contactName.split(' ');
|
||||
const firstName = nameParts[0] || 'Unknown';
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
leadData.name = contactName;
|
||||
leadData.contactName = {
|
||||
firstName,
|
||||
lastName: lastName || undefined,
|
||||
};
|
||||
|
||||
if (body.contact_phone || body.contactPhone) {
|
||||
const phone = body.contact_phone || body.contactPhone;
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
leadData.contactPhone = {
|
||||
primaryPhoneNumber: cleanPhone.startsWith('91')
|
||||
? `+${cleanPhone}`
|
||||
: `+91${cleanPhone}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (body.contact_email || body.contactEmail) {
|
||||
leadData.contactEmail = {
|
||||
primaryEmail: body.contact_email || body.contactEmail,
|
||||
};
|
||||
}
|
||||
|
||||
leadData.source = body.source || 'WEBSITE';
|
||||
leadData.status = body.lead_status || body.status || 'NEW';
|
||||
|
||||
const interestedService = this.mapInterestedService(body);
|
||||
if (interestedService) {
|
||||
leadData.interestedService = interestedService;
|
||||
}
|
||||
|
||||
if (body.assigned_agent || body.assignedAgent) {
|
||||
leadData.assignedAgent = body.assigned_agent || body.assignedAgent;
|
||||
}
|
||||
|
||||
if (body.campaign_id || body.campaignId) {
|
||||
leadData.campaignId = body.campaign_id || body.campaignId;
|
||||
}
|
||||
|
||||
return leadData;
|
||||
}
|
||||
|
||||
private mapInterestedService(body: Record<string, any>): string | null {
|
||||
const type = body.type || body.interested_service || body.interestedService;
|
||||
|
||||
if (!type) {
|
||||
return body.department || null;
|
||||
}
|
||||
|
||||
const serviceMap: Record<string, string> = {
|
||||
consultation: 'Appointment',
|
||||
follow_up: 'Appointment',
|
||||
procedure: 'Appointment',
|
||||
emergency: 'Appointment',
|
||||
general_enquiry: 'General Enquiry',
|
||||
general: 'General Enquiry',
|
||||
};
|
||||
|
||||
return serviceMap[type.toLowerCase()] || type;
|
||||
}
|
||||
|
||||
private async createInitialActivity(
|
||||
leadId: string,
|
||||
body: Record<string, any>,
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activityType =
|
||||
body.type === 'consultation' || body.type === 'appointment'
|
||||
? 'APPOINTMENT_BOOKED'
|
||||
: 'CALL_RECEIVED';
|
||||
|
||||
let summary = 'Lead submitted via web form';
|
||||
if (body.type) {
|
||||
summary = `${body.type.replace(/_/g, ' ')} requested`;
|
||||
}
|
||||
if (body.department) {
|
||||
summary += ` - ${body.department}`;
|
||||
}
|
||||
if (body.title) {
|
||||
summary += ` (from ${body.title})`;
|
||||
}
|
||||
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: summary.substring(0, 80),
|
||||
activityType,
|
||||
summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: 'System',
|
||||
channel: 'PHONE',
|
||||
leadId,
|
||||
},
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(`Initial activity created for lead ${leadId}`);
|
||||
} catch (error: any) {
|
||||
const errorDetails = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: error.message;
|
||||
this.logger.error(`Failed to create initial activity: ${errorDetails}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ export class ExotelController {
|
||||
@Post('call-status')
|
||||
@HttpCode(200)
|
||||
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
|
||||
this.logger.log(
|
||||
`Received Exotel webhook: ${payload.event_details?.event_type}`,
|
||||
);
|
||||
|
||||
const callEvent = this.exotelService.parseWebhook(payload);
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ export class ExotelService {
|
||||
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||
const { event_details, call_details } = payload;
|
||||
|
||||
const eventType = event_details.event_type === 'answered' ? 'answered'
|
||||
: event_details.event_type === 'terminal' ? 'ended'
|
||||
const eventType =
|
||||
event_details.event_type === 'answered'
|
||||
? 'answered'
|
||||
: event_details.event_type === 'terminal'
|
||||
? 'ended'
|
||||
: 'ringing';
|
||||
|
||||
const callEvent: CallEvent = {
|
||||
@@ -25,7 +28,9 @@ export class ExotelService {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
|
||||
this.logger.log(
|
||||
`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
|
||||
);
|
||||
return callEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Controller, Post, Req, Res, Logger, HttpException } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
Logger,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
@@ -21,16 +28,12 @@ export class GraphqlProxyController {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
req.body,
|
||||
{
|
||||
const response = await axios.post(this.graphqlUrl, req.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -18,10 +18,14 @@ export class HealthController {
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, {
|
||||
await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query: '{ __typename }' },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
});
|
||||
},
|
||||
);
|
||||
platformLatency = Date.now() - start;
|
||||
platformReachable = true;
|
||||
} catch {
|
||||
|
||||
22
src/main.ts
22
src/main.ts
@@ -1,18 +1,38 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
const corsOrigins = config.get<string[]>('corsOrigins') || [
|
||||
'http://localhost:5173',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: config.get('corsOrigin'),
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
|
||||
});
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Helix Engage Server')
|
||||
.setDescription(
|
||||
'Sidecar API — Ozonetel telephony + FortyTwo platform bridge',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = config.get('port');
|
||||
await app.listen(port);
|
||||
console.log(`Helix Engage Server running on port ${port}`);
|
||||
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -20,12 +20,16 @@ export class KookooIvrController {
|
||||
const cid = query.cid ?? '';
|
||||
const status = query.status ?? '';
|
||||
|
||||
this.logger.log(`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`);
|
||||
this.logger.log(
|
||||
`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`,
|
||||
);
|
||||
|
||||
// New outbound call — customer answered, put them in a conference room
|
||||
// The room ID is based on the call SID so we can join from the browser
|
||||
if (event === 'NewCall') {
|
||||
this.logger.log(`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`);
|
||||
this.logger.log(
|
||||
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
|
||||
);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
|
||||
|
||||
@@ -12,7 +12,8 @@ export class OzonetelAgentService {
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||
this.apiDomain =
|
||||
config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
||||
}
|
||||
@@ -29,9 +30,13 @@ export class OzonetelAgentService {
|
||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||
this.logger.log('Generating CloudAgent API token');
|
||||
|
||||
const response = await axios.post(url, { userName: this.accountId }, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{ userName: this.accountId },
|
||||
{
|
||||
headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (data.token) {
|
||||
@@ -49,7 +54,6 @@ export class OzonetelAgentService {
|
||||
this.tokenExpiry = 0;
|
||||
}
|
||||
|
||||
|
||||
async loginAgent(params: {
|
||||
agentId: string;
|
||||
password: string;
|
||||
@@ -58,7 +62,9 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; message: string }> {
|
||||
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
||||
|
||||
this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`);
|
||||
this.logger.log(
|
||||
`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
@@ -85,10 +91,18 @@ export class OzonetelAgentService {
|
||||
const data = response.data;
|
||||
|
||||
// "already logged in" — force logout + re-login to refresh SIP phone mapping
|
||||
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
||||
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`);
|
||||
if (
|
||||
data.status === 'error' &&
|
||||
data.message?.includes('already logged in')
|
||||
) {
|
||||
this.logger.log(
|
||||
`Agent ${params.agentId} already logged in — forcing logout + re-login`,
|
||||
);
|
||||
try {
|
||||
await this.logoutAgent({ agentId: params.agentId, password: params.password });
|
||||
await this.logoutAgent({
|
||||
agentId: params.agentId,
|
||||
password: params.password,
|
||||
});
|
||||
const retryResponse = await axios.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
@@ -104,7 +118,9 @@ export class OzonetelAgentService {
|
||||
auth: { username: params.agentId, password: params.password },
|
||||
},
|
||||
);
|
||||
this.logger.log(`Agent re-login response: ${JSON.stringify(retryResponse.data)}`);
|
||||
this.logger.log(
|
||||
`Agent re-login response: ${JSON.stringify(retryResponse.data)}`,
|
||||
);
|
||||
return retryResponse.data;
|
||||
} catch (retryErr: any) {
|
||||
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
|
||||
@@ -128,27 +144,35 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; ucid?: string; message?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
|
||||
|
||||
this.logger.log(`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`);
|
||||
this.logger.log(
|
||||
`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios.post(url, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
userName: this.accountId,
|
||||
agentID: params.agentId,
|
||||
campaignName: params.campaignName,
|
||||
customerNumber: params.customerNumber,
|
||||
UCID: 'true',
|
||||
}, {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) this.invalidateToken();
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
||||
throw error;
|
||||
}
|
||||
@@ -161,7 +185,9 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; message: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
|
||||
|
||||
this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`);
|
||||
this.logger.log(
|
||||
`Changing agent ${params.agentId} state to ${params.state}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
@@ -181,11 +207,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Change agent state response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Change agent state response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Change agent state failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Change agent state failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -198,11 +230,15 @@ export class OzonetelAgentService {
|
||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
|
||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
||||
this.logger.log(
|
||||
`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios.post(url, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
userName: this.accountId,
|
||||
agentID: params.agentId,
|
||||
did,
|
||||
@@ -210,18 +246,26 @@ export class OzonetelAgentService {
|
||||
action: 'Set',
|
||||
disposition: params.disposition,
|
||||
autoRelease: 'true',
|
||||
}, {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Set disposition response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Set disposition failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Set disposition failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -235,7 +279,9 @@ export class OzonetelAgentService {
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
|
||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
||||
this.logger.log(
|
||||
`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
@@ -257,11 +303,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Call control response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Call control failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -284,11 +336,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Recording control response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Recording control failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +355,8 @@ export class OzonetelAgentService {
|
||||
fromTime?: string;
|
||||
toTime?: string;
|
||||
campaignName?: string;
|
||||
}): Promise<Array<{
|
||||
}): Promise<
|
||||
Array<{
|
||||
monitorUCID: string;
|
||||
type: string;
|
||||
status: string;
|
||||
@@ -308,7 +367,8 @@ export class OzonetelAgentService {
|
||||
agent: string;
|
||||
hangupBy: string;
|
||||
callTime: string;
|
||||
}>> {
|
||||
}>
|
||||
> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
|
||||
|
||||
this.logger.log('Fetching abandon calls');
|
||||
@@ -331,7 +391,9 @@ export class OzonetelAgentService {
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`);
|
||||
this.logger.log(
|
||||
`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`,
|
||||
);
|
||||
if (data.status === 'success' && Array.isArray(data.message)) {
|
||||
return data.message;
|
||||
}
|
||||
@@ -380,13 +442,18 @@ export class OzonetelAgentService {
|
||||
}
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
||||
async getAgentSummary(
|
||||
agentId: string,
|
||||
date: string,
|
||||
): Promise<{
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
@@ -415,7 +482,9 @@ export class OzonetelAgentService {
|
||||
|
||||
const data = response.data;
|
||||
if (data.status === 'success' && data.message) {
|
||||
const record = Array.isArray(data.message) ? data.message[0] : data.message;
|
||||
const record = Array.isArray(data.message)
|
||||
? data.message[0]
|
||||
: data.message;
|
||||
return {
|
||||
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
|
||||
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
|
||||
@@ -492,7 +561,9 @@ export class OzonetelAgentService {
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Agent logout response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) this.invalidateToken();
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
||||
import type {
|
||||
LeadNode,
|
||||
LeadActivityNode,
|
||||
CreateCallInput,
|
||||
CreateLeadActivityInput,
|
||||
CreateLeadInput,
|
||||
UpdateLeadInput,
|
||||
} from './platform.types';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformGraphqlService {
|
||||
@@ -19,14 +26,18 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
|
||||
// Query using a passed-through auth header (user JWT)
|
||||
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
||||
async queryWithAuth<T>(
|
||||
query: string,
|
||||
variables: Record<string, any> | undefined,
|
||||
authHeader: string,
|
||||
): Promise<T> {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query, variables },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -63,10 +74,16 @@ export class PlatformGraphqlService {
|
||||
|
||||
// Client-side phone matching (strip non-digits for comparison)
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
return data.leads.edges.find(edge => {
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const leadPhones = edge.node.contactPhone ?? [];
|
||||
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, '')));
|
||||
})?.node ?? null;
|
||||
return leadPhones.some(
|
||||
(p) =>
|
||||
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
|
||||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
|
||||
);
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async findLeadById(id: string): Promise<LeadNode | null> {
|
||||
@@ -110,7 +127,9 @@ export class PlatformGraphqlService {
|
||||
return data.createCall;
|
||||
}
|
||||
|
||||
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> {
|
||||
async createLeadActivity(
|
||||
input: CreateLeadActivityInput,
|
||||
): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
@@ -120,13 +139,28 @@ export class PlatformGraphqlService {
|
||||
return data.createLeadActivity;
|
||||
}
|
||||
|
||||
async createLead(input: CreateLeadInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLead: { id: string } }>(
|
||||
`mutation CreateLead($data: LeadCreateInput!) {
|
||||
createLead(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
return data.createLead;
|
||||
}
|
||||
|
||||
// --- Token passthrough versions (for user-driven requests) ---
|
||||
|
||||
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||
async findLeadByPhoneWithToken(
|
||||
phone: string,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode | null> {
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
const last10 = normalizedPhone.slice(-10);
|
||||
|
||||
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||
const data = await this.queryWithAuth<{
|
||||
leads: { edges: { node: LeadNode }[] };
|
||||
}>(
|
||||
`query FindLeads($first: Int) {
|
||||
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
|
||||
edges {
|
||||
@@ -148,22 +182,37 @@ export class PlatformGraphqlService {
|
||||
);
|
||||
|
||||
// Client-side phone matching
|
||||
return data.leads.edges.find(edge => {
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const phones = edge.node.contactPhone ?? [];
|
||||
if (Array.isArray(phones)) {
|
||||
return phones.some((p: any) => {
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
);
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
});
|
||||
}
|
||||
// Handle single phone object
|
||||
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, '');
|
||||
const num = (
|
||||
(phones as any).primaryPhoneNumber ??
|
||||
(phones as any).number ??
|
||||
''
|
||||
).replace(/\D/g, '');
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
})?.node ?? null;
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
async getLeadActivitiesWithToken(
|
||||
leadId: string,
|
||||
authHeader: string,
|
||||
limit = 5,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||
edges {
|
||||
@@ -176,10 +225,14 @@ export class PlatformGraphqlService {
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
authHeader,
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
|
||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||
async updateLeadWithToken(
|
||||
id: string,
|
||||
input: UpdateLeadInput,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode> {
|
||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) {
|
||||
@@ -194,8 +247,13 @@ export class PlatformGraphqlService {
|
||||
|
||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||
|
||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
async getLeadActivities(
|
||||
leadId: string,
|
||||
limit = 3,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
||||
edges {
|
||||
@@ -207,6 +265,6 @@ export class PlatformGraphqlService {
|
||||
}`,
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,19 @@ export type CreateLeadActivityInput = {
|
||||
leadId: string;
|
||||
};
|
||||
|
||||
export type CreateLeadInput = {
|
||||
name: string;
|
||||
contactName?: { firstName: string; lastName?: string };
|
||||
contactPhone?: { primaryPhoneNumber: string };
|
||||
contactEmail?: { primaryEmailAddress: string };
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
assignedAgent?: string;
|
||||
campaignId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateLeadInput = {
|
||||
leadStatus?: string;
|
||||
lastContactedAt?: string;
|
||||
|
||||
@@ -20,7 +20,9 @@ export class SearchController {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
const authHeader = this.platformApiKey
|
||||
? `Bearer ${this.platformApiKey}`
|
||||
: '';
|
||||
if (!authHeader) {
|
||||
return { leads: [], patients: [], appointments: [] };
|
||||
}
|
||||
@@ -29,31 +31,41 @@ export class SearchController {
|
||||
|
||||
// Fetch all three in parallel, filter client-side for flexible matching
|
||||
try {
|
||||
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
||||
this.platform.queryWithAuth<any>(
|
||||
const [leadsResult, patientsResult, appointmentsResult] =
|
||||
await Promise.all([
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ leads(first: 50) { edges { node {
|
||||
id name contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
source status interestedService
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ leads: { edges: [] } })),
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ leads: { edges: [] } })),
|
||||
|
||||
this.platform.queryWithAuth<any>(
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ patients(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
gender dateOfBirth
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ patients: { edges: [] } })),
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ patients: { edges: [] } })),
|
||||
|
||||
this.platform.queryWithAuth<any>(
|
||||
this.platform
|
||||
.queryWithAuth<any>(
|
||||
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt doctorName department status patientId
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
).catch(() => ({ appointments: { edges: [] } })),
|
||||
undefined,
|
||||
authHeader,
|
||||
)
|
||||
.catch(() => ({ appointments: { edges: [] } })),
|
||||
]);
|
||||
|
||||
const q = query.toLowerCase();
|
||||
@@ -61,18 +73,28 @@ export class SearchController {
|
||||
const leads = (leadsResult.leads?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((l: any) => {
|
||||
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const name =
|
||||
`${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
||||
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q);
|
||||
return (
|
||||
name.includes(q) ||
|
||||
phone.includes(q) ||
|
||||
(l.name ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const patients = (patientsResult.patients?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((p: any) => {
|
||||
const name = `${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||
const name =
|
||||
`${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q);
|
||||
return (
|
||||
name.includes(q) ||
|
||||
phone.includes(q) ||
|
||||
(p.name ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ export class KookooCallbackController {
|
||||
}
|
||||
|
||||
@Post('callback')
|
||||
async handleCallback(@Body() body: Record<string, any>, @Query() query: Record<string, any>) {
|
||||
async handleCallback(
|
||||
@Body() body: Record<string, any>,
|
||||
@Query() query: Record<string, any>,
|
||||
) {
|
||||
// Kookoo sends params as both query and body
|
||||
const params = { ...query, ...body };
|
||||
this.logger.log(`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`);
|
||||
this.logger.log(
|
||||
`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`,
|
||||
);
|
||||
|
||||
const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
|
||||
const status = params.status ?? 'unknown';
|
||||
@@ -50,7 +55,9 @@ export class KookooCallbackController {
|
||||
direction: 'OUTBOUND',
|
||||
callStatus,
|
||||
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
|
||||
startedAt: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||
startedAt: startTime
|
||||
? new Date(startTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
endedAt: endTime ? new Date(endTime).toISOString() : null,
|
||||
durationSec: duration,
|
||||
},
|
||||
@@ -58,7 +65,9 @@ export class KookooCallbackController {
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`);
|
||||
this.logger.log(
|
||||
`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`,
|
||||
);
|
||||
|
||||
// Try to match to a lead
|
||||
const leadResult = await this.platform.queryWithAuth<any>(
|
||||
@@ -69,7 +78,10 @@ export class KookooCallbackController {
|
||||
const leads = leadResult.leads.edges.map((e: any) => e.node);
|
||||
const cleanPhone = phoneNumber.replace(/\D/g, '');
|
||||
const matchedLead = leads.find((l: any) => {
|
||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
);
|
||||
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
|
||||
});
|
||||
|
||||
@@ -79,13 +91,23 @@ export class KookooCallbackController {
|
||||
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } },
|
||||
authHeader,
|
||||
);
|
||||
this.logger.log(`Linked call to lead ${matchedLead.id} (${matchedLead.name})`);
|
||||
this.logger.log(
|
||||
`Linked call to lead ${matchedLead.id} (${matchedLead.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { received: true, processed: true, callId: callResult.createCall.id };
|
||||
return {
|
||||
received: true,
|
||||
processed: true,
|
||||
callId: callResult.createCall.id,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||
this.logger.error(`Kookoo callback processing failed: ${err.message} ${responseData}`);
|
||||
const responseData = err?.response?.data
|
||||
? JSON.stringify(err.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Kookoo callback processing failed: ${err.message} ${responseData}`,
|
||||
);
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user