6 Commits

Author SHA1 Message Date
Kartik Datrika
603ec7c612 Merge branch 'dev-main' into dev-kartik 2026-04-06 11:18:28 +05:30
Kartik Datrika
bb20f5102a Merge branch 'dev-main' into dev-kartik 2026-03-27 16:57:22 +05:30
Kartik Datrika
c80dddee0f Update package.json 2026-03-25 11:06:01 +05:30
Kartik Datrika
bb46549a4d Merge branch 'dev' into dev-kartik 2026-03-23 17:21:00 +05:30
Kartik Datrika
33ec8f5db8 Name update 2026-03-23 17:20:03 +05:30
Kartik Datrika
a1157ab4c1 lint and format 2026-03-23 15:46:32 +05:30
38 changed files with 8779 additions and 1523 deletions

32
.claudeignore Normal file
View 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
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -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. 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 ## Architecture
@@ -27,7 +27,7 @@ This server has **no database**. All persistent data flows to/from the FortyTwo
| Repo | Purpose | Owner | | Repo | Purpose | Owner |
|------|---------|-------| |------|---------|-------|
| `helix-engage` | React frontend | Mouli | | `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 | | `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
## Getting Started ## Getting Started

View File

@@ -29,6 +29,16 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': '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" }], "prettier/prettier": ["error", { endOfLine: "auto" }],
}, },
}, },

View File

@@ -17,7 +17,8 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.58", "@ai-sdk/anthropic": "^3.0.58",
@@ -31,6 +32,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.17", "@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/swagger": "^11.2.6",
"@nestjs/websockets": "^11.1.17", "@nestjs/websockets": "^11.1.17",
"ai": "^6.0.116", "ai": "^6.0.116",
"axios": "^1.13.6", "axios": "^1.13.6",
@@ -56,7 +58,9 @@
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0", "globals": "^16.0.0",
"husky": "^9.1.7",
"jest": "^30.0.0", "jest": "^30.0.0",
"lint-staged": "^16.4.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
@@ -68,6 +72,16 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
],
"test/**/*.ts": [
"eslint --fix",
"prettier --write"
]
},
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",

6909
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,12 @@ type EnrichmentResult = {
}; };
const enrichmentSchema = z.object({ const enrichmentSchema = z.object({
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'), aiSummary: z
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'), .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() @Injectable()
@@ -46,15 +50,20 @@ export class AiEnrichmentService {
try { try {
const daysSince = lead.createdAt 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; : 0;
const activitiesText = lead.activities?.length 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'; : 'No previous interactions';
const { object } = await generateObject({ const { object } = await generateObject({
model: this.aiModel!, model: this.aiModel,
schema: enrichmentSchema, schema: enrichmentSchema,
prompt: `You are an AI assistant for a hospital call center. 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. 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}`, ${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; return object;
} catch (error) { } catch (error) {
this.logger.error(`AI enrichment failed: ${error}`); this.logger.error(`AI enrichment failed: ${error}`);
@@ -81,12 +92,16 @@ ${activitiesText}`,
private fallbackEnrichment(lead: LeadContext): EnrichmentResult { private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
const daysSince = lead.createdAt 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; : 0;
const attempts = lead.contactAttempts ?? 0; const attempts = lead.contactAttempts ?? 0;
const service = lead.interestedService ?? 'general inquiry'; 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 summary: string;
let action: string; let action: string;

View File

@@ -21,6 +21,7 @@ export function createAiModel(config: ConfigService): LanguageModel | null {
export function isAiConfigured(config: ConfigService): boolean { export function isAiConfigured(config: ConfigService): boolean {
const provider = config.get<string>('ai.provider') ?? 'openai'; 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'); return !!config.get<string>('ai.openaiApiKey');
} }

View File

@@ -23,7 +23,10 @@ export class AgentConfigService {
private platform: PlatformGraphqlService, private platform: PlatformGraphqlService,
private config: ConfigService, 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'); this.sipWsPort = config.get<string>('sip.wsPort', '444');
} }
@@ -46,13 +49,18 @@ export class AgentConfigService {
ozonetelAgentId: node.ozonetelagentid, ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension, sipExtension: node.sipextension,
sipPassword: node.sippassword ?? 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}`, sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`, sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
}; };
this.cache.set(memberId, agentConfig); 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; return agentConfig;
} catch (err) { } catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`); this.logger.warn(`Failed to fetch agent config: ${err}`);

View File

@@ -33,15 +33,20 @@ export class CallAssistGateway implements OnGatewayDisconnect {
@SubscribeMessage('call-assist:start') @SubscribeMessage('call-assist:start')
async handleStart( async handleStart(
@ConnectedSocket() client: Socket, @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( const context = await this.callAssist.loadCallContext(
data.leadId ?? null, data.leadId ?? null,
data.callerPhone ?? 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 = { const session: SessionState = {
deepgramWs: null, deepgramWs: null,
@@ -87,13 +92,18 @@ export class CallAssistGateway implements OnGatewayDisconnect {
session.deepgramWs = dgWs; session.deepgramWs = dgWs;
} else { } else {
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled'); 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 // AI suggestion every 10 seconds
session.suggestionTimer = setInterval(async () => { session.suggestionTimer = setInterval(async () => {
if (!session.transcript.trim()) return; 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) { if (suggestion) {
client.emit('call-assist:suggestion', { text: suggestion }); client.emit('call-assist:suggestion', { text: suggestion });
} }
@@ -128,7 +138,9 @@ export class CallAssistGateway implements OnGatewayDisconnect {
if (session) { if (session) {
if (session.suggestionTimer) clearInterval(session.suggestionTimer); if (session.suggestionTimer) clearInterval(session.suggestionTimer);
if (session.deepgramWs) { if (session.deepgramWs) {
try { session.deepgramWs.close(); } catch {} try {
session.deepgramWs.close();
} catch {}
} }
this.sessions.delete(clientId); this.sessions.delete(clientId);
} }

View File

@@ -19,8 +19,13 @@ export class CallAssistService {
this.platformApiKey = config.get<string>('platform.apiKey') ?? ''; this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
} }
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> { async loadCallContext(
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : ''; leadId: string | null,
callerPhone: string | null,
): Promise<string> {
const authHeader = this.platformApiKey
? `Bearer ${this.platformApiKey}`
: '';
if (!authHeader) return 'No platform context available.'; if (!authHeader) return 'No platform context available.';
try { try {
@@ -35,7 +40,8 @@ export class CallAssistService {
lastContacted contactAttempts lastContacted contactAttempts
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`, } } } }`,
undefined, authHeader, undefined,
authHeader,
); );
const lead = leadResult.leads.edges[0]?.node; const lead = leadResult.leads.edges[0]?.node;
if (lead) { if (lead) {
@@ -43,9 +49,13 @@ export class CallAssistService {
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim() ? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
: lead.name; : lead.name;
parts.push(`CALLER: ${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(`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}`); parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`); if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
} }
@@ -54,7 +64,8 @@ export class CallAssistService {
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { `{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit patientId id scheduledAt status doctorName department reasonForVisit patientId
} } } }`, } } } }`,
undefined, authHeader, undefined,
authHeader,
); );
const appts = apptResult.appointments.edges const appts = apptResult.appointments.edges
.map((e: any) => e.node) .map((e: any) => e.node)
@@ -62,8 +73,12 @@ export class CallAssistService {
if (appts.length > 0) { if (appts.length > 0) {
parts.push('\nPAST APPOINTMENTS:'); parts.push('\nPAST APPOINTMENTS:');
for (const a of appts) { for (const a of appts) {
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?'; const date = a.scheduledAt
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`); ? new Date(a.scheduledAt).toLocaleDateString('en-IN')
: '?';
parts.push(
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`,
);
} }
} }
} else if (callerPhone) { } else if (callerPhone) {
@@ -75,14 +90,19 @@ export class CallAssistService {
`{ doctors(first: 20) { edges { node { `{ doctors(first: 20) { edges { node {
fullName { firstName lastName } department specialty clinic { clinicName } fullName { firstName lastName } department specialty clinic { clinicName }
} } } }`, } } } }`,
undefined, authHeader, undefined,
authHeader,
); );
const docs = docResult.doctors.edges.map((e: any) => e.node); const docs = docResult.doctors.edges.map((e: any) => e.node);
if (docs.length > 0) { if (docs.length > 0) {
parts.push('\nAVAILABLE DOCTORS:'); parts.push('\nAVAILABLE DOCTORS:');
for (const d of docs) { for (const d of docs) {
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown'; const name = d.fullName
parts.push(`- ${name}${d.department ?? '?'} ${d.clinic?.clinicName ?? '?'}`); ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
: 'Unknown';
parts.push(
`- ${name}${d.department ?? '?'}${d.clinic?.clinicName ?? '?'}`,
);
} }
} }

View File

@@ -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 { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AiEnrichmentService } from '../ai/ai-enrichment.service'; import { AiEnrichmentService } from '../ai/ai-enrichment.service';
@@ -33,11 +40,17 @@ export class CallLookupController {
} }
if (lead) { 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 // Get recent activities
try { try {
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5); activities = await this.platform.getLeadActivitiesWithToken(
lead.id,
authHeader,
5,
);
} catch (err) { } catch (err) {
this.logger.warn(`Activity fetch failed: ${err}`); this.logger.warn(`Activity fetch failed: ${err}`);
} }
@@ -64,10 +77,14 @@ export class CallLookupController {
// Persist AI enrichment back to platform // Persist AI enrichment back to platform
try { try {
await this.platform.updateLeadWithToken(lead.id, { await this.platform.updateLeadWithToken(
lead.id,
{
aiSummary: enrichment.aiSummary, aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction, aiSuggestedAction: enrichment.aiSuggestedAction,
}, authHeader); },
authHeader,
);
} catch (err) { } catch (err) {
this.logger.warn(`Failed to persist AI enrichment: ${err}`); this.logger.warn(`Failed to persist AI enrichment: ${err}`);
} }

View File

@@ -2,10 +2,11 @@ export default () => ({
port: parseInt(process.env.PORT ?? '4100', 10), port: parseInt(process.env.PORT ?? '4100', 10),
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173') corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
.split(',') .split(',')
.map(origin => origin.trim()) .map((origin) => origin.trim())
.filter(origin => origin.length > 0), .filter((origin) => origin.length > 0),
platform: { 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 ?? '', apiKey: process.env.PLATFORM_API_KEY ?? '',
}, },
exotel: { exotel: {
@@ -23,7 +24,10 @@ export default () => ({
wsPort: process.env.SIP_WS_PORT ?? '444', wsPort: process.env.SIP_WS_PORT ?? '444',
}, },
missedQueue: { missedQueue: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), pollIntervalMs: parseInt(
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
10,
),
}, },
ai: { ai: {
provider: process.env.AI_PROVIDER ?? 'openai', provider: process.env.AI_PROVIDER ?? 'openai',

View File

@@ -16,8 +16,10 @@ export class LeadEmbedController {
@Post('create') @Post('create')
async handleLeadCreation(@Body() body: Record<string, any>) { async handleLeadCreation(@Body() body: Record<string, any>) {
console.log("Lead creation from embed received:", body); console.log('Lead creation from embed received:', body);
this.logger.log(`Lead creation from embed received: ${JSON.stringify(body)}`); this.logger.log(
`Lead creation from embed received: ${JSON.stringify(body)}`,
);
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : ''; const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
if (!authHeader) { if (!authHeader) {
@@ -29,7 +31,10 @@ export class LeadEmbedController {
const leadData = this.mapIncomingDataToLead(body); const leadData = this.mapIncomingDataToLead(body);
if (!leadData.contactPhone && !leadData.contactEmail) { if (!leadData.contactPhone && !leadData.contactEmail) {
throw new HttpException('Either contact phone or email is required', 400); throw new HttpException(
'Either contact phone or email is required',
400,
);
} }
const result = await this.platform.queryWithAuth<any>( const result = await this.platform.queryWithAuth<any>(
@@ -51,8 +56,12 @@ export class LeadEmbedController {
message: 'Lead created successfully', message: 'Lead created successfully',
}; };
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
this.logger.error(`Lead creation failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Lead creation failed: ${error.message} ${responseData}`,
);
if (error instanceof HttpException) { if (error instanceof HttpException) {
throw error; throw error;
@@ -65,7 +74,9 @@ export class LeadEmbedController {
} }
} }
private mapIncomingDataToLead(body: Record<string, any>): Record<string, any> { private mapIncomingDataToLead(
body: Record<string, any>,
): Record<string, any> {
const leadData: Record<string, any> = {}; const leadData: Record<string, any> = {};
const contactName = body.contact_name || body.contactName || 'Unknown'; const contactName = body.contact_name || body.contactName || 'Unknown';
@@ -83,7 +94,9 @@ export class LeadEmbedController {
const phone = body.contact_phone || body.contactPhone; const phone = body.contact_phone || body.contactPhone;
const cleanPhone = phone.replace(/\D/g, ''); const cleanPhone = phone.replace(/\D/g, '');
leadData.contactPhone = { leadData.contactPhone = {
primaryPhoneNumber: cleanPhone.startsWith('91') ? `+${cleanPhone}` : `+91${cleanPhone}`, primaryPhoneNumber: cleanPhone.startsWith('91')
? `+${cleanPhone}`
: `+91${cleanPhone}`,
}; };
} }
@@ -120,12 +133,12 @@ export class LeadEmbedController {
} }
const serviceMap: Record<string, string> = { const serviceMap: Record<string, string> = {
'consultation': 'Appointment', consultation: 'Appointment',
'follow_up': 'Appointment', follow_up: 'Appointment',
'procedure': 'Appointment', procedure: 'Appointment',
'emergency': 'Appointment', emergency: 'Appointment',
'general_enquiry': 'General Enquiry', general_enquiry: 'General Enquiry',
'general': 'General Enquiry', general: 'General Enquiry',
}; };
return serviceMap[type.toLowerCase()] || type; return serviceMap[type.toLowerCase()] || type;
@@ -137,7 +150,8 @@ export class LeadEmbedController {
authHeader: string, authHeader: string,
): Promise<void> { ): Promise<void> {
try { try {
const activityType = body.type === 'consultation' || body.type === 'appointment' const activityType =
body.type === 'consultation' || body.type === 'appointment'
? 'APPOINTMENT_BOOKED' ? 'APPOINTMENT_BOOKED'
: 'CALL_RECEIVED'; : 'CALL_RECEIVED';
@@ -170,7 +184,9 @@ export class LeadEmbedController {
this.logger.log(`Initial activity created for lead ${leadId}`); this.logger.log(`Initial activity created for lead ${leadId}`);
} catch (error: any) { } catch (error: any) {
const errorDetails = error?.response?.data ? JSON.stringify(error.response.data) : error.message; const errorDetails = error?.response?.data
? JSON.stringify(error.response.data)
: error.message;
this.logger.error(`Failed to create initial activity: ${errorDetails}`); this.logger.error(`Failed to create initial activity: ${errorDetails}`);
} }
} }

View File

@@ -15,7 +15,9 @@ export class ExotelController {
@Post('call-status') @Post('call-status')
@HttpCode(200) @HttpCode(200)
async handleCallStatus(@Body() payload: ExotelWebhookPayload) { 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); const callEvent = this.exotelService.parseWebhook(payload);

View File

@@ -8,8 +8,11 @@ export class ExotelService {
parseWebhook(payload: ExotelWebhookPayload): CallEvent { parseWebhook(payload: ExotelWebhookPayload): CallEvent {
const { event_details, call_details } = payload; const { event_details, call_details } = payload;
const eventType = event_details.event_type === 'answered' ? 'answered' const eventType =
: event_details.event_type === 'terminal' ? 'ended' event_details.event_type === 'answered'
? 'answered'
: event_details.event_type === 'terminal'
? 'ended'
: 'ringing'; : 'ringing';
const callEvent: CallEvent = { const callEvent: CallEvent = {
@@ -25,7 +28,9 @@ export class ExotelService {
timestamp: new Date().toISOString(), 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; return callEvent;
} }
} }

View File

@@ -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 { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import axios from 'axios'; import axios from 'axios';
@@ -21,16 +28,12 @@ export class GraphqlProxyController {
} }
try { try {
const response = await axios.post( const response = await axios.post(this.graphqlUrl, req.body, {
this.graphqlUrl,
req.body,
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': authHeader, Authorization: authHeader,
}, },
}, });
);
res.status(response.status).json(response.data); res.status(response.status).json(response.data);
} catch (error: any) { } catch (error: any) {

View File

@@ -18,10 +18,14 @@ export class HealthController {
try { try {
const start = Date.now(); const start = Date.now();
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, { await axios.post(
this.graphqlUrl,
{ query: '{ __typename }' },
{
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
timeout: 5000, timeout: 5000,
}); },
);
platformLatency = Date.now() - start; platformLatency = Date.now() - start;
platformReachable = true; platformReachable = true;
} catch { } catch {

View File

@@ -1,12 +1,15 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService); const config = app.get(ConfigService);
const corsOrigins = config.get<string[]>('corsOrigins') || ['http://localhost:5173']; const corsOrigins = config.get<string[]>('corsOrigins') || [
'http://localhost:5173',
];
app.enableCors({ app.enableCors({
origin: corsOrigins, origin: corsOrigins,
@@ -15,8 +18,21 @@ async function bootstrap() {
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'], 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'); const port = config.get('port');
await app.listen(port); await app.listen(port);
console.log(`Helix Engage Server running on port ${port}`); console.log(`Helix Engage Server running on port ${port}`);
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
} }
bootstrap(); bootstrap();

View File

@@ -20,12 +20,16 @@ export class KookooIvrController {
const cid = query.cid ?? ''; const cid = query.cid ?? '';
const status = query.status ?? ''; 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 // 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 // The room ID is based on the call SID so we can join from the browser
if (event === 'NewCall') { 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"?> return `<?xml version="1.0" encoding="UTF-8"?>
<response> <response>
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial> <dial record="true" timeout="30" moh="ring">${this.callerId}</dial>

View File

@@ -12,7 +12,8 @@ export class OzonetelAgentService {
private tokenExpiry: number = 0; private tokenExpiry: number = 0;
constructor(private config: ConfigService) { 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.apiKey = config.get<string>('exotel.apiKey') ?? '';
this.accountId = config.get<string>('exotel.accountSid') ?? ''; this.accountId = config.get<string>('exotel.accountSid') ?? '';
} }
@@ -29,9 +30,13 @@ export class OzonetelAgentService {
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`; const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
this.logger.log('Generating CloudAgent API token'); 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' }, headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
}); },
);
const data = response.data; const data = response.data;
if (data.token) { if (data.token) {
@@ -49,7 +54,6 @@ export class OzonetelAgentService {
this.tokenExpiry = 0; this.tokenExpiry = 0;
} }
async loginAgent(params: { async loginAgent(params: {
agentId: string; agentId: string;
password: string; password: string;
@@ -58,7 +62,9 @@ export class OzonetelAgentService {
}): Promise<{ status: string; message: string }> { }): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`; 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 { try {
const response = await axios.post( const response = await axios.post(
@@ -85,10 +91,18 @@ export class OzonetelAgentService {
const data = response.data; const data = response.data;
// "already logged in" — force logout + re-login to refresh SIP phone mapping // "already logged in" — force logout + re-login to refresh SIP phone mapping
if (data.status === 'error' && data.message?.includes('already logged in')) { if (
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`); data.status === 'error' &&
data.message?.includes('already logged in')
) {
this.logger.log(
`Agent ${params.agentId} already logged in — forcing logout + re-login`,
);
try { try {
await this.logoutAgent({ agentId: params.agentId, password: params.password }); await this.logoutAgent({
agentId: params.agentId,
password: params.password,
});
const retryResponse = await axios.post( const retryResponse = await axios.post(
url, url,
new URLSearchParams({ new URLSearchParams({
@@ -104,7 +118,9 @@ export class OzonetelAgentService {
auth: { username: params.agentId, password: params.password }, 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; return retryResponse.data;
} catch (retryErr: any) { } catch (retryErr: any) {
this.logger.error(`Agent re-login failed: ${retryErr.message}`); this.logger.error(`Agent re-login failed: ${retryErr.message}`);
@@ -128,27 +144,35 @@ export class OzonetelAgentService {
}): Promise<{ status: string; ucid?: string; message?: string }> { }): Promise<{ status: string; ucid?: string; message?: string }> {
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`; 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 { try {
const token = await this.getToken(); const token = await this.getToken();
const response = await axios.post(url, { const response = await axios.post(
url,
{
userName: this.accountId, userName: this.accountId,
agentID: params.agentId, agentID: params.agentId,
campaignName: params.campaignName, campaignName: params.campaignName,
customerNumber: params.customerNumber, customerNumber: params.customerNumber,
UCID: 'true', UCID: 'true',
}, { },
{
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); },
);
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`); this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken(); 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}`); this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
throw error; throw error;
} }
@@ -161,7 +185,9 @@ export class OzonetelAgentService {
}): Promise<{ status: string; message: string }> { }): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`; 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 { try {
const body: Record<string, string> = { 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; return response.data;
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
this.logger.error(`Change agent state failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Change agent state failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -198,11 +230,15 @@ export class OzonetelAgentService {
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`; const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
const did = process.env.OZONETEL_DID ?? '918041763265'; 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 { try {
const token = await this.getToken(); const token = await this.getToken();
const response = await axios.post(url, { const response = await axios.post(
url,
{
userName: this.accountId, userName: this.accountId,
agentID: params.agentId, agentID: params.agentId,
did, did,
@@ -210,18 +246,26 @@ export class OzonetelAgentService {
action: 'Set', action: 'Set',
disposition: params.disposition, disposition: params.disposition,
autoRelease: 'true', autoRelease: 'true',
}, { },
{
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', '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; return response.data;
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
this.logger.error(`Set disposition failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Set disposition failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -235,7 +279,9 @@ export class OzonetelAgentService {
const did = process.env.OZONETEL_DID ?? '918041763265'; const did = process.env.OZONETEL_DID ?? '918041763265';
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590'; 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 { try {
const token = await this.getToken(); 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; return response.data;
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
this.logger.error(`Call control failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Call control failed: ${error.message} ${responseData}`,
);
throw error; 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; return response.data;
} catch (error: any) { } catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; const responseData = error?.response?.data
this.logger.error(`Recording control failed: ${error.message} ${responseData}`); ? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Recording control failed: ${error.message} ${responseData}`,
);
throw error; throw error;
} }
} }
@@ -297,7 +355,8 @@ export class OzonetelAgentService {
fromTime?: string; fromTime?: string;
toTime?: string; toTime?: string;
campaignName?: string; campaignName?: string;
}): Promise<Array<{ }): Promise<
Array<{
monitorUCID: string; monitorUCID: string;
type: string; type: string;
status: string; status: string;
@@ -308,7 +367,8 @@ export class OzonetelAgentService {
agent: string; agent: string;
hangupBy: string; hangupBy: string;
callTime: string; callTime: string;
}>> { }>
> {
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`; const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
this.logger.log('Fetching abandon calls'); this.logger.log('Fetching abandon calls');
@@ -331,7 +391,9 @@ export class OzonetelAgentService {
}); });
const data = response.data; 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)) { if (data.status === 'success' && Array.isArray(data.message)) {
return data.message; return data.message;
} }
@@ -380,13 +442,18 @@ export class OzonetelAgentService {
} }
return []; return [];
} catch (error: any) { } 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}`); this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
return []; return [];
} }
} }
async getAgentSummary(agentId: string, date: string): Promise<{ async getAgentSummary(
agentId: string,
date: string,
): Promise<{
totalLoginDuration: string; totalLoginDuration: string;
totalBusyTime: string; totalBusyTime: string;
totalIdleTime: string; totalIdleTime: string;
@@ -415,7 +482,9 @@ export class OzonetelAgentService {
const data = response.data; const data = response.data;
if (data.status === 'success' && data.message) { 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 { return {
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00', totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
totalBusyTime: record.TotalBusyTime ?? '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; return response.data;
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken(); if (error?.response?.status === 401) this.invalidateToken();

View File

@@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, CreateLeadInput, UpdateLeadInput } from './platform.types'; import type {
LeadNode,
LeadActivityNode,
CreateCallInput,
CreateLeadActivityInput,
CreateLeadInput,
UpdateLeadInput,
} from './platform.types';
@Injectable() @Injectable()
export class PlatformGraphqlService { export class PlatformGraphqlService {
@@ -19,14 +26,18 @@ export class PlatformGraphqlService {
} }
// Query using a passed-through auth header (user JWT) // 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( const response = await axios.post(
this.graphqlUrl, this.graphqlUrl,
{ query, variables }, { query, variables },
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': authHeader, Authorization: authHeader,
}, },
}, },
); );
@@ -63,10 +74,16 @@ export class PlatformGraphqlService {
// Client-side phone matching (strip non-digits for comparison) // Client-side phone matching (strip non-digits for comparison)
const normalizedPhone = phone.replace(/\D/g, ''); const normalizedPhone = phone.replace(/\D/g, '');
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const leadPhones = edge.node.contactPhone ?? []; const leadPhones = edge.node.contactPhone ?? [];
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, ''))); return leadPhones.some(
})?.node ?? null; (p) =>
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
);
})?.node ?? null
);
} }
async findLeadById(id: string): Promise<LeadNode | null> { async findLeadById(id: string): Promise<LeadNode | null> {
@@ -110,7 +127,9 @@ export class PlatformGraphqlService {
return data.createCall; 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 } }>( const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) { `mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id } createLeadActivity(data: $data) { id }
@@ -132,11 +151,16 @@ export class PlatformGraphqlService {
// --- Token passthrough versions (for user-driven requests) --- // --- 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 normalizedPhone = phone.replace(/\D/g, '');
const last10 = normalizedPhone.slice(-10); 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) { `query FindLeads($first: Int) {
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
edges { edges {
@@ -158,22 +182,37 @@ export class PlatformGraphqlService {
); );
// Client-side phone matching // Client-side phone matching
return data.leads.edges.find(edge => { return (
data.leads.edges.find((edge) => {
const phones = edge.node.contactPhone ?? []; const phones = edge.node.contactPhone ?? [];
if (Array.isArray(phones)) { if (Array.isArray(phones)) {
return phones.some((p: any) => { 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); return num.endsWith(last10) || last10.endsWith(num);
}); });
} }
// Handle single phone object // 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); return num.endsWith(last10) || last10.endsWith(num);
})?.node ?? null; })?.node ?? null
);
} }
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> { async getLeadActivitiesWithToken(
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( leadId: string,
authHeader: string,
limit = 5,
): Promise<LeadActivityNode[]> {
const data = await this.queryWithAuth<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { edges {
@@ -186,10 +225,14 @@ export class PlatformGraphqlService {
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
authHeader, 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 }>( const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { updateLead(id: $id, data: $data) {
@@ -204,8 +247,13 @@ export class PlatformGraphqlService {
// --- Server-to-server versions (for webhooks, background jobs) --- // --- Server-to-server versions (for webhooks, background jobs) ---
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> { async getLeadActivities(
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>( leadId: string,
limit = 3,
): Promise<LeadActivityNode[]> {
const data = await this.query<{
leadActivities: { edges: { node: LeadActivityNode }[] };
}>(
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { `query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
edges { edges {
@@ -217,6 +265,6 @@ export class PlatformGraphqlService {
}`, }`,
{ filter: { leadId: { eq: leadId } }, first: limit }, { filter: { leadId: { eq: leadId } }, first: limit },
); );
return data.leadActivities.edges.map(e => e.node); return data.leadActivities.edges.map((e) => e.node);
} }
} }

View File

@@ -20,7 +20,9 @@ export class SearchController {
return { leads: [], patients: [], appointments: [] }; return { leads: [], patients: [], appointments: [] };
} }
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : ''; const authHeader = this.platformApiKey
? `Bearer ${this.platformApiKey}`
: '';
if (!authHeader) { if (!authHeader) {
return { leads: [], patients: [], appointments: [] }; return { leads: [], patients: [], appointments: [] };
} }
@@ -29,31 +31,41 @@ export class SearchController {
// Fetch all three in parallel, filter client-side for flexible matching // Fetch all three in parallel, filter client-side for flexible matching
try { try {
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([ const [leadsResult, patientsResult, appointmentsResult] =
this.platform.queryWithAuth<any>( await Promise.all([
this.platform
.queryWithAuth<any>(
`{ leads(first: 50) { edges { node { `{ leads(first: 50) { edges { node {
id name contactName { firstName lastName } id name contactName { firstName lastName }
contactPhone { primaryPhoneNumber } contactPhone { primaryPhoneNumber }
source status interestedService source status interestedService
} } } }`, } } } }`,
undefined, authHeader, undefined,
).catch(() => ({ leads: { edges: [] } })), authHeader,
)
.catch(() => ({ leads: { edges: [] } })),
this.platform.queryWithAuth<any>( this.platform
.queryWithAuth<any>(
`{ patients(first: 50) { edges { node { `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName } id name fullName { firstName lastName }
phones { primaryPhoneNumber } phones { primaryPhoneNumber }
gender dateOfBirth gender dateOfBirth
} } } }`, } } } }`,
undefined, authHeader, undefined,
).catch(() => ({ patients: { edges: [] } })), authHeader,
)
.catch(() => ({ patients: { edges: [] } })),
this.platform.queryWithAuth<any>( this.platform
.queryWithAuth<any>(
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { `{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt doctorName department status patientId id scheduledAt doctorName department status patientId
} } } }`, } } } }`,
undefined, authHeader, undefined,
).catch(() => ({ appointments: { edges: [] } })), authHeader,
)
.catch(() => ({ appointments: { edges: [] } })),
]); ]);
const q = query.toLowerCase(); const q = query.toLowerCase();
@@ -61,18 +73,28 @@ export class SearchController {
const leads = (leadsResult.leads?.edges ?? []) const leads = (leadsResult.leads?.edges ?? [])
.map((e: any) => e.node) .map((e: any) => e.node)
.filter((l: any) => { .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 ?? ''; 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); .slice(0, 5);
const patients = (patientsResult.patients?.edges ?? []) const patients = (patientsResult.patients?.edges ?? [])
.map((e: any) => e.node) .map((e: any) => e.node)
.filter((p: any) => { .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 ?? ''; 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); .slice(0, 5);

View File

@@ -15,10 +15,15 @@ export class KookooCallbackController {
} }
@Post('callback') @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 // Kookoo sends params as both query and body
const params = { ...query, ...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 phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
const status = params.status ?? 'unknown'; const status = params.status ?? 'unknown';
@@ -50,7 +55,9 @@ export class KookooCallbackController {
direction: 'OUTBOUND', direction: 'OUTBOUND',
callStatus, callStatus,
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` }, 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, endedAt: endTime ? new Date(endTime).toISOString() : null,
durationSec: duration, durationSec: duration,
}, },
@@ -58,7 +65,9 @@ export class KookooCallbackController {
authHeader, 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 // Try to match to a lead
const leadResult = await this.platform.queryWithAuth<any>( 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 leads = leadResult.leads.edges.map((e: any) => e.node);
const cleanPhone = phoneNumber.replace(/\D/g, ''); const cleanPhone = phoneNumber.replace(/\D/g, '');
const matchedLead = leads.find((l: any) => { 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); return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
}); });
@@ -79,13 +91,23 @@ export class KookooCallbackController {
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } }, { id: callResult.createCall.id, data: { leadId: matchedLead.id } },
authHeader, 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) { } catch (err: any) {
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : ''; const responseData = err?.response?.data
this.logger.error(`Kookoo callback processing failed: ${err.message} ${responseData}`); ? JSON.stringify(err.response.data)
: '';
this.logger.error(
`Kookoo callback processing failed: ${err.message} ${responseData}`,
);
return { received: true, processed: false }; return { received: true, processed: false };
} }
} }