9 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
05eb7a326e Merge branch 'dev' into dev-main 2026-04-06 11:15:03 +05:30
Kartik Datrika
bb20f5102a Merge branch 'dev-main' into dev-kartik 2026-03-27 16:57:22 +05:30
moulichand16
09c7930b52 fixed cors 2026-03-27 16:05:18 +05:30
moulichand16
e912b982df added script forms 2026-03-27 10:53:20 +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
40 changed files with 8827 additions and 1355 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

View File

@@ -1,6 +1,6 @@
# Server # Server
PORT=4100 PORT=4100
CORS_ORIGIN=http://localhost:5173 CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Fortytwo Platform # Fortytwo Platform
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql

1
.husky/pre-commit Normal file
View File

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

View File

@@ -1,4 +1,4 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "trailingComma": "all"
} }

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",
@@ -85,4 +99,4 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

6909
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,57 +6,66 @@ import { z } from 'zod';
import { createAiModel } from './ai-provider'; import { createAiModel } from './ai-provider';
type LeadContext = { type LeadContext = {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
leadSource?: string; leadSource?: string;
interestedService?: string; interestedService?: string;
leadStatus?: string; leadStatus?: string;
contactAttempts?: number; contactAttempts?: number;
createdAt?: string; createdAt?: string;
campaignId?: string; campaignId?: string;
activities?: { activityType: string; summary: string }[]; activities?: { activityType: string; summary: string }[];
}; };
type EnrichmentResult = { type EnrichmentResult = {
aiSummary: string; aiSummary: string;
aiSuggestedAction: string; aiSuggestedAction: string;
}; };
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()
export class AiEnrichmentService { export class AiEnrichmentService {
private readonly logger = new Logger(AiEnrichmentService.name); private readonly logger = new Logger(AiEnrichmentService.name);
private readonly aiModel: LanguageModel | null; private readonly aiModel: LanguageModel | null;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.aiModel = createAiModel(config); this.aiModel = createAiModel(config);
if (!this.aiModel) { if (!this.aiModel) {
this.logger.warn('AI not configured — enrichment uses fallback'); this.logger.warn('AI not configured — enrichment uses fallback');
} }
}
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
if (!this.aiModel) {
return this.fallbackEnrichment(lead);
} }
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> { try {
if (!this.aiModel) { const daysSince = lead.createdAt
return this.fallbackEnrichment(lead); ? Math.floor(
} (Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0;
try { const activitiesText = lead.activities?.length
const daysSince = lead.createdAt ? lead.activities
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) .map((a) => `- ${a.activityType}: ${a.summary}`)
: 0; .join('\n')
: 'No previous interactions';
const activitiesText = lead.activities?.length const { object } = await generateObject({
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n') model: this.aiModel,
: 'No previous interactions'; schema: enrichmentSchema,
prompt: `You are an AI assistant for a hospital call center.
const { object } = await generateObject({
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. An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details: Lead details:
@@ -69,39 +78,45 @@ Lead details:
Recent activity: Recent activity:
${activitiesText}`, ${activitiesText}`,
}); });
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`); this.logger.log(
return object; `AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
} catch (error) { );
this.logger.error(`AI enrichment failed: ${error}`); return object;
return this.fallbackEnrichment(lead); } catch (error) {
} this.logger.error(`AI enrichment failed: ${error}`);
return this.fallbackEnrichment(lead);
}
}
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
const daysSince = lead.createdAt
? 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';
let summary: string;
let action: string;
if (attempts === 0) {
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
action = `Introduce services and offer appointment booking`;
} else if (attempts === 1) {
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
action = `Follow up on previous conversation, offer appointment`;
} else {
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
action = `Prioritize appointment booking — high-intent lead`;
} }
private fallbackEnrichment(lead: LeadContext): EnrichmentResult { return { aiSummary: summary, aiSuggestedAction: action };
const daysSince = lead.createdAt }
? 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';
let summary: string;
let action: string;
if (attempts === 0) {
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
action = `Introduce services and offer appointment booking`;
} else if (attempts === 1) {
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
action = `Follow up on previous conversation, offer appointment`;
} else {
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
action = `Prioritize appointment booking — high-intent lead`;
}
return { aiSummary: summary, aiSuggestedAction: action };
}
} }

View File

@@ -4,23 +4,24 @@ import { openai } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai'; import type { LanguageModel } from 'ai';
export function createAiModel(config: ConfigService): LanguageModel | null { export function createAiModel(config: ConfigService): LanguageModel | null {
const provider = config.get<string>('ai.provider') ?? 'openai'; const provider = config.get<string>('ai.provider') ?? 'openai';
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini'; const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
if (provider === 'anthropic') { if (provider === 'anthropic') {
const apiKey = config.get<string>('ai.anthropicApiKey'); const apiKey = config.get<string>('ai.anthropicApiKey');
if (!apiKey) return null;
return anthropic(model);
}
// Default to openai
const apiKey = config.get<string>('ai.openaiApiKey');
if (!apiKey) return null; if (!apiKey) return null;
return openai(model); return anthropic(model);
}
// Default to openai
const apiKey = config.get<string>('ai.openaiApiKey');
if (!apiKey) return null;
return openai(model);
} }
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.openaiApiKey'); return !!config.get<string>('ai.anthropicApiKey');
return !!config.get<string>('ai.openaiApiKey');
} }

View File

@@ -4,9 +4,9 @@ import { AiEnrichmentService } from './ai-enrichment.service';
import { AiChatController } from './ai-chat.controller'; import { AiChatController } from './ai-chat.controller';
@Module({ @Module({
imports: [PlatformModule], imports: [PlatformModule],
controllers: [AiChatController], controllers: [AiChatController],
providers: [AiEnrichmentService], providers: [AiEnrichmentService],
exports: [AiEnrichmentService], exports: [AiEnrichmentService],
}) })
export class AiModule {} export class AiModule {}

View File

@@ -3,68 +3,76 @@ import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type AgentConfig = { export type AgentConfig = {
id: string; id: string;
ozonetelAgentId: string; ozonetelAgentId: string;
sipExtension: string; sipExtension: string;
sipPassword: string; sipPassword: string;
campaignName: string; campaignName: string;
sipUri: string; sipUri: string;
sipWsServer: string; sipWsServer: string;
}; };
@Injectable() @Injectable()
export class AgentConfigService { export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name); private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>(); private readonly cache = new Map<string, AgentConfig>();
private readonly sipDomain: string; private readonly sipDomain: string;
private readonly sipWsPort: string; private readonly sipWsPort: string;
constructor( constructor(
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>(
this.sipWsPort = config.get<string>('sip.wsPort', '444'); 'sip.domain',
} 'blr-pub-rtc4.ozonetel.com',
);
this.sipWsPort = config.get<string>('sip.wsPort', '444');
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> { async getByMemberId(memberId: string): Promise<AgentConfig | null> {
const cached = this.cache.get(memberId); const cached = this.cache.get(memberId);
if (cached) return cached; if (cached) return cached;
try { try {
const data = await this.platform.query<any>( const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node { `{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname id ozonetelagentid sipextension sippassword campaignname
} } } }`, } } } }`,
); );
const node = data?.agents?.edges?.[0]?.node; const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null; if (!node || !node.ozonetelagentid || !node.sipextension) return null;
const agentConfig: AgentConfig = { const agentConfig: AgentConfig = {
id: node.id, id: node.id,
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:
sipUri: `sip:${node.sipextension}@${this.sipDomain}`, node.campaignname ??
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`, 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.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`); this.logger.log(
return agentConfig; `Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`,
} catch (err) { );
this.logger.warn(`Failed to fetch agent config: ${err}`); return agentConfig;
return null; } catch (err) {
} this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
} }
}
getFromCache(memberId: string): AgentConfig | null { getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null; return this.cache.get(memberId) ?? null;
} }
clearCache(memberId: string): void { clearCache(memberId: string): void {
this.cache.delete(memberId); this.cache.delete(memberId);
} }
} }

View File

@@ -6,9 +6,9 @@ import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service'; import { AgentConfigService } from './agent-config.service';
@Module({ @Module({
imports: [OzonetelAgentModule, PlatformModule], imports: [OzonetelAgentModule, PlatformModule],
controllers: [AuthController], controllers: [AuthController],
providers: [SessionService, AgentConfigService], providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService], exports: [SessionService, AgentConfigService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,9 +1,9 @@
import { import {
WebSocketGateway, WebSocketGateway,
SubscribeMessage, SubscribeMessage,
MessageBody, MessageBody,
ConnectedSocket, ConnectedSocket,
OnGatewayDisconnect, OnGatewayDisconnect,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
@@ -11,126 +11,138 @@ import WebSocket from 'ws';
import { CallAssistService } from './call-assist.service'; import { CallAssistService } from './call-assist.service';
type SessionState = { type SessionState = {
deepgramWs: WebSocket | null; deepgramWs: WebSocket | null;
transcript: string; transcript: string;
context: string; context: string;
suggestionTimer: NodeJS.Timeout | null; suggestionTimer: NodeJS.Timeout | null;
}; };
@WebSocketGateway({ @WebSocketGateway({
cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true }, cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true },
namespace: '/call-assist', namespace: '/call-assist',
}) })
export class CallAssistGateway implements OnGatewayDisconnect { export class CallAssistGateway implements OnGatewayDisconnect {
private readonly logger = new Logger(CallAssistGateway.name); private readonly logger = new Logger(CallAssistGateway.name);
private readonly sessions = new Map<string, SessionState>(); private readonly sessions = new Map<string, SessionState>();
private readonly deepgramApiKey: string; private readonly deepgramApiKey: string;
constructor(private readonly callAssist: CallAssistService) { constructor(private readonly callAssist: CallAssistService) {
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? ''; this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
}
@SubscribeMessage('call-assist:start')
async handleStart(
@ConnectedSocket() client: Socket,
@MessageBody()
data: { ucid: string; leadId?: string; callerPhone?: string },
) {
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) + '...',
});
const session: SessionState = {
deepgramWs: null,
transcript: '',
context,
suggestionTimer: null,
};
if (this.deepgramApiKey) {
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
const dgWs = new WebSocket(dgUrl, {
headers: { Authorization: `Token ${this.deepgramApiKey}` },
});
dgWs.on('open', () => {
this.logger.log(`Deepgram connected for ${data.ucid}`);
});
dgWs.on('message', (raw: WebSocket.Data) => {
try {
const result = JSON.parse(raw.toString());
const text = result.channel?.alternatives?.[0]?.transcript;
if (!text) return;
const isFinal = result.is_final;
client.emit('call-assist:transcript', { text, isFinal });
if (isFinal) {
session.transcript += `Customer: ${text}\n`;
}
} catch {}
});
dgWs.on('error', (err) => {
this.logger.error(`Deepgram error: ${err.message}`);
});
dgWs.on('close', () => {
this.logger.log(`Deepgram closed for ${data.ucid}`);
});
session.deepgramWs = dgWs;
} else {
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
client.emit('call-assist:error', {
message: 'Transcription not configured',
});
} }
@SubscribeMessage('call-assist:start') // AI suggestion every 10 seconds
async handleStart( session.suggestionTimer = setInterval(async () => {
@ConnectedSocket() client: Socket, if (!session.transcript.trim()) return;
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string }, const suggestion = await this.callAssist.getSuggestion(
) { session.transcript,
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`); session.context,
);
if (suggestion) {
client.emit('call-assist:suggestion', { text: suggestion });
}
}, 10000);
const context = await this.callAssist.loadCallContext( this.sessions.set(client.id, session);
data.leadId ?? null, }
data.callerPhone ?? null,
);
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' });
const session: SessionState = { @SubscribeMessage('call-assist:audio')
deepgramWs: null, handleAudio(
transcript: '', @ConnectedSocket() client: Socket,
context, @MessageBody() audioData: ArrayBuffer,
suggestionTimer: null, ) {
}; const session = this.sessions.get(client.id);
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
if (this.deepgramApiKey) { session.deepgramWs.send(Buffer.from(audioData));
const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`;
const dgWs = new WebSocket(dgUrl, {
headers: { Authorization: `Token ${this.deepgramApiKey}` },
});
dgWs.on('open', () => {
this.logger.log(`Deepgram connected for ${data.ucid}`);
});
dgWs.on('message', (raw: WebSocket.Data) => {
try {
const result = JSON.parse(raw.toString());
const text = result.channel?.alternatives?.[0]?.transcript;
if (!text) return;
const isFinal = result.is_final;
client.emit('call-assist:transcript', { text, isFinal });
if (isFinal) {
session.transcript += `Customer: ${text}\n`;
}
} catch {}
});
dgWs.on('error', (err) => {
this.logger.error(`Deepgram error: ${err.message}`);
});
dgWs.on('close', () => {
this.logger.log(`Deepgram closed for ${data.ucid}`);
});
session.deepgramWs = dgWs;
} else {
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
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);
if (suggestion) {
client.emit('call-assist:suggestion', { text: suggestion });
}
}, 10000);
this.sessions.set(client.id, session);
} }
}
@SubscribeMessage('call-assist:audio') @SubscribeMessage('call-assist:stop')
handleAudio( handleStop(@ConnectedSocket() client: Socket) {
@ConnectedSocket() client: Socket, this.cleanup(client.id);
@MessageBody() audioData: ArrayBuffer, this.logger.log(`Call assist stopped: ${client.id}`);
) { }
const session = this.sessions.get(client.id);
if (session?.deepgramWs?.readyState === WebSocket.OPEN) {
session.deepgramWs.send(Buffer.from(audioData));
}
}
@SubscribeMessage('call-assist:stop') handleDisconnect(client: Socket) {
handleStop(@ConnectedSocket() client: Socket) { this.cleanup(client.id);
this.cleanup(client.id); }
this.logger.log(`Call assist stopped: ${client.id}`);
}
handleDisconnect(client: Socket) { private cleanup(clientId: string) {
this.cleanup(client.id); const session = this.sessions.get(clientId);
} if (session) {
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
private cleanup(clientId: string) { if (session.deepgramWs) {
const session = this.sessions.get(clientId); try {
if (session) { session.deepgramWs.close();
if (session.suggestionTimer) clearInterval(session.suggestionTimer); } catch {}
if (session.deepgramWs) { }
try { session.deepgramWs.close(); } catch {} this.sessions.delete(clientId);
}
this.sessions.delete(clientId);
}
} }
}
} }

View File

@@ -4,7 +4,7 @@ import { CallAssistService } from './call-assist.service';
import { PlatformModule } from '../platform/platform.module'; import { PlatformModule } from '../platform/platform.module';
@Module({ @Module({
imports: [PlatformModule], imports: [PlatformModule],
providers: [CallAssistGateway, CallAssistService], providers: [CallAssistGateway, CallAssistService],
}) })
export class CallAssistModule {} export class CallAssistModule {}

View File

@@ -7,99 +7,119 @@ import type { LanguageModel } from 'ai';
@Injectable() @Injectable()
export class CallAssistService { export class CallAssistService {
private readonly logger = new Logger(CallAssistService.name); private readonly logger = new Logger(CallAssistService.name);
private readonly aiModel: LanguageModel | null; private readonly aiModel: LanguageModel | null;
private readonly platformApiKey: string; private readonly platformApiKey: string;
constructor( constructor(
private config: ConfigService, private config: ConfigService,
private platform: PlatformGraphqlService, private platform: PlatformGraphqlService,
) { ) {
this.aiModel = createAiModel(config); this.aiModel = createAiModel(config);
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,
if (!authHeader) return 'No platform context available.'; callerPhone: string | null,
): Promise<string> {
const authHeader = this.platformApiKey
? `Bearer ${this.platformApiKey}`
: '';
if (!authHeader) return 'No platform context available.';
try { try {
const parts: string[] = []; const parts: string[] = [];
if (leadId) { if (leadId) {
const leadResult = await this.platform.queryWithAuth<any>( const leadResult = await this.platform.queryWithAuth<any>(
`{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node { `{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node {
id name contactName { firstName lastName } id name contactName { firstName lastName }
contactPhone { primaryPhoneNumber } contactPhone { primaryPhoneNumber }
source status interestedService source status interestedService
lastContacted contactAttempts lastContacted contactAttempts
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`, } } } }`,
undefined, authHeader, undefined,
); authHeader,
const lead = leadResult.leads.edges[0]?.node; );
if (lead) { const lead = leadResult.leads.edges[0]?.node;
const name = lead.contactName if (lead) {
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim() const name = lead.contactName
: lead.name; ? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
parts.push(`CALLER: ${name}`); : lead.name;
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`); parts.push(`CALLER: ${name}`);
parts.push(`Source: ${lead.source ?? 'Unknown'}`); parts.push(
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`); `Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`); );
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`); parts.push(`Source: ${lead.source ?? 'Unknown'}`);
} parts.push(
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
);
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
}
const apptResult = await this.platform.queryWithAuth<any>( const apptResult = await this.platform.queryWithAuth<any>(
`{ 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 );
.map((e: any) => e.node) const appts = apptResult.appointments.edges
.filter((a: any) => a.patientId === leadId); .map((e: any) => e.node)
if (appts.length > 0) { .filter((a: any) => a.patientId === leadId);
parts.push('\nPAST APPOINTMENTS:'); if (appts.length > 0) {
for (const a of appts) { parts.push('\nPAST APPOINTMENTS:');
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?'; for (const a of appts) {
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`); const date = a.scheduledAt
} ? new Date(a.scheduledAt).toLocaleDateString('en-IN')
} : '?';
} else if (callerPhone) { parts.push(
parts.push(`CALLER: Unknown (${callerPhone})`); `- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`,
parts.push('No lead record found — this may be a new enquiry.'); );
} }
}
} else if (callerPhone) {
parts.push(`CALLER: Unknown (${callerPhone})`);
parts.push('No lead record found — this may be a new enquiry.');
}
const docResult = await this.platform.queryWithAuth<any>( const docResult = await this.platform.queryWithAuth<any>(
`{ 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); );
if (docs.length > 0) { const docs = docResult.doctors.edges.map((e: any) => e.node);
parts.push('\nAVAILABLE DOCTORS:'); if (docs.length > 0) {
for (const d of docs) { parts.push('\nAVAILABLE DOCTORS:');
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown'; for (const d of docs) {
parts.push(`- ${name}${d.department ?? '?'}${d.clinic?.clinicName ?? '?'}`); const name = d.fullName
} ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
} : 'Unknown';
parts.push(
return parts.join('\n') || 'No context available.'; `- ${name}${d.department ?? '?'}${d.clinic?.clinicName ?? '?'}`,
} catch (err) { );
this.logger.error(`Failed to load call context: ${err}`);
return 'Context loading failed.';
} }
}
return parts.join('\n') || 'No context available.';
} catch (err) {
this.logger.error(`Failed to load call context: ${err}`);
return 'Context loading failed.';
} }
}
async getSuggestion(transcript: string, context: string): Promise<string> { async getSuggestion(transcript: string, context: string): Promise<string> {
if (!this.aiModel || !transcript.trim()) return ''; if (!this.aiModel || !transcript.trim()) return '';
try { try {
const { text } = await generateText({ const { text } = await generateText({
model: this.aiModel, model: this.aiModel,
system: `You are a real-time call assistant for Global Hospital Bangalore. system: `You are a real-time call assistant for Global Hospital Bangalore.
You listen to the customer's words and provide brief, actionable suggestions for the CC agent. You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
${context} ${context}
@@ -111,13 +131,13 @@ RULES:
- If customer wants to cancel or reschedule, note relevant appointment details - If customer wants to cancel or reschedule, note relevant appointment details
- If customer sounds upset, suggest empathetic response - If customer sounds upset, suggest empathetic response
- Do NOT repeat what the agent already knows`, - Do NOT repeat what the agent already knows`,
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`, prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
maxOutputTokens: 150, maxOutputTokens: 150,
}); });
return text; return text;
} catch (err) { } catch (err) {
this.logger.error(`AI suggestion failed: ${err}`); this.logger.error(`AI suggestion failed: ${err}`);
return ''; return '';
}
} }
}
} }

View File

@@ -6,9 +6,9 @@ import { CallEventsGateway } from './call-events.gateway';
import { CallLookupController } from './call-lookup.controller'; import { CallLookupController } from './call-lookup.controller';
@Module({ @Module({
imports: [PlatformModule, AiModule], imports: [PlatformModule, AiModule],
controllers: [CallLookupController], controllers: [CallLookupController],
providers: [CallEventsService, CallEventsGateway], providers: [CallEventsService, CallEventsGateway],
exports: [CallEventsService, CallEventsGateway], exports: [CallEventsService, CallEventsGateway],
}) })
export class CallEventsModule {} export class CallEventsModule {}

View File

@@ -1,38 +1,38 @@
export type EnrichedCallEvent = { export type EnrichedCallEvent = {
callSid: string; callSid: string;
eventType: 'ringing' | 'answered' | 'ended'; eventType: 'ringing' | 'answered' | 'ended';
lead: { lead: {
id: string; id: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
phone: string; phone: string;
email?: string; email?: string;
source?: string; source?: string;
status?: string; status?: string;
campaign?: string; campaign?: string;
interestedService?: string; interestedService?: string;
age: number; age: number;
aiSummary?: string; aiSummary?: string;
aiSuggestedAction?: string; aiSuggestedAction?: string;
recentActivities: { recentActivities: {
activityType: string; activityType: string;
summary: string; summary: string;
occurredAt: string; occurredAt: string;
performedBy: string; performedBy: string;
}[]; }[];
} | null; } | null;
callerPhone: string; callerPhone: string;
agentName: string; agentName: string;
timestamp: string; timestamp: string;
}; };
export type DispositionPayload = { export type DispositionPayload = {
callSid: string; callSid: string;
leadId: string | null; leadId: string | null;
disposition: string; disposition: string;
notes: string; notes: string;
agentName: string; agentName: string;
callerPhone: string; callerPhone: string;
startedAt: string; startedAt: string;
duration: number; duration: number;
}; };

View File

@@ -1,88 +1,105 @@
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';
@Controller('api/call') @Controller('api/call')
export class CallLookupController { export class CallLookupController {
private readonly logger = new Logger(CallLookupController.name); private readonly logger = new Logger(CallLookupController.name);
constructor( constructor(
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly ai: AiEnrichmentService, private readonly ai: AiEnrichmentService,
) {} ) {}
@Post('lookup') @Post('lookup')
async lookupCaller( async lookupCaller(
@Body() body: { phoneNumber: string }, @Body() body: { phoneNumber: string },
@Headers('authorization') authHeader: string, @Headers('authorization') authHeader: string,
) { ) {
if (!authHeader) throw new HttpException('Authorization required', 401); if (!authHeader) throw new HttpException('Authorization required', 401);
if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400); if (!body.phoneNumber) throw new HttpException('phoneNumber required', 400);
const phone = body.phoneNumber.replace(/^0+/, ''); const phone = body.phoneNumber.replace(/^0+/, '');
this.logger.log(`Looking up caller: ${phone}`); this.logger.log(`Looking up caller: ${phone}`);
// Query platform for leads matching this phone number // Query platform for leads matching this phone number
let lead = null; let lead = null;
let activities: any[] = []; let activities: any[] = [];
try { try {
lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader); lead = await this.platform.findLeadByPhoneWithToken(phone, authHeader);
} catch (err) { } catch (err) {
this.logger.warn(`Lead lookup failed: ${err}`); this.logger.warn(`Lead lookup failed: ${err}`);
}
if (lead) {
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);
} catch (err) {
this.logger.warn(`Activity fetch failed: ${err}`);
}
// AI enrichment if no existing summary
if (!lead.aiSummary) {
try {
const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName,
lastName: lead.contactName?.lastName,
leadSource: lead.leadSource ?? undefined,
interestedService: lead.interestedService ?? undefined,
leadStatus: lead.leadStatus ?? undefined,
contactAttempts: lead.contactAttempts ?? undefined,
createdAt: lead.createdAt,
activities: activities.map((a: any) => ({
activityType: a.activityType ?? '',
summary: a.summary ?? '',
})),
});
lead.aiSummary = enrichment.aiSummary;
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
// Persist AI enrichment back to platform
try {
await this.platform.updateLeadWithToken(lead.id, {
aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction,
}, authHeader);
} catch (err) {
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
}
} catch (err) {
this.logger.warn(`AI enrichment failed: ${err}`);
}
}
} else {
this.logger.log(`No lead found for phone ${phone}`);
}
return {
lead,
activities,
matched: lead !== null,
};
} }
if (lead) {
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,
);
} catch (err) {
this.logger.warn(`Activity fetch failed: ${err}`);
}
// AI enrichment if no existing summary
if (!lead.aiSummary) {
try {
const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName,
lastName: lead.contactName?.lastName,
leadSource: lead.leadSource ?? undefined,
interestedService: lead.interestedService ?? undefined,
leadStatus: lead.leadStatus ?? undefined,
contactAttempts: lead.contactAttempts ?? undefined,
createdAt: lead.createdAt,
activities: activities.map((a: any) => ({
activityType: a.activityType ?? '',
summary: a.summary ?? '',
})),
});
lead.aiSummary = enrichment.aiSummary;
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
// Persist AI enrichment back to platform
try {
await this.platform.updateLeadWithToken(
lead.id,
{
aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction,
},
authHeader,
);
} catch (err) {
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
}
} catch (err) {
this.logger.warn(`AI enrichment failed: ${err}`);
}
}
} else {
this.logger.log(`No lead found for phone ${phone}`);
}
return {
lead,
activities,
matched: lead !== null,
};
}
} }

View File

@@ -1,31 +1,38 @@
export default () => ({ export default () => ({
port: parseInt(process.env.PORT ?? '4100', 10), port: parseInt(process.env.PORT ?? '4100', 10),
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173', corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
platform: { .split(',')
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql', .map((origin) => origin.trim())
apiKey: process.env.PLATFORM_API_KEY ?? '', .filter((origin) => origin.length > 0),
}, platform: {
exotel: { graphqlUrl:
apiKey: process.env.EXOTEL_API_KEY ?? '', process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
apiToken: process.env.EXOTEL_API_TOKEN ?? '', apiKey: process.env.PLATFORM_API_KEY ?? '',
accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '', },
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com', exotel: {
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '', apiKey: process.env.EXOTEL_API_KEY ?? '',
}, apiToken: process.env.EXOTEL_API_TOKEN ?? '',
redis: { accountSid: process.env.EXOTEL_ACCOUNT_SID ?? '',
url: process.env.REDIS_URL ?? 'redis://localhost:6379', subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
}, webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
sip: { },
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com', redis: {
wsPort: process.env.SIP_WS_PORT ?? '444', url: process.env.REDIS_URL ?? 'redis://localhost:6379',
}, },
missedQueue: { sip: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
}, wsPort: process.env.SIP_WS_PORT ?? '444',
ai: { },
provider: process.env.AI_PROVIDER ?? 'openai', missedQueue: {
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', pollIntervalMs: parseInt(
openaiApiKey: process.env.OPENAI_API_KEY ?? '', process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
model: process.env.AI_MODEL ?? 'gpt-4o-mini', 10,
}, ),
},
ai: {
provider: process.env.AI_PROVIDER ?? 'openai',
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
},
}); });

View 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 {}

View 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}`);
}
}
}

View File

@@ -5,26 +5,28 @@ import type { ExotelWebhookPayload } from './exotel.types';
@Controller('webhooks/exotel') @Controller('webhooks/exotel')
export class ExotelController { export class ExotelController {
private readonly logger = new Logger(ExotelController.name); private readonly logger = new Logger(ExotelController.name);
constructor( constructor(
private readonly exotelService: ExotelService, private readonly exotelService: ExotelService,
private readonly callEventsService: CallEventsService, private readonly callEventsService: CallEventsService,
) {} ) {}
@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);
if (callEvent.eventType === 'answered') { if (callEvent.eventType === 'answered') {
await this.callEventsService.handleIncomingCall(callEvent); await this.callEventsService.handleIncomingCall(callEvent);
} else if (callEvent.eventType === 'ended') { } else if (callEvent.eventType === 'ended') {
await this.callEventsService.handleCallEnded(callEvent); await this.callEventsService.handleCallEnded(callEvent);
}
return { status: 'received' };
} }
return { status: 'received' };
}
} }

View File

@@ -4,9 +4,9 @@ import { ExotelController } from './exotel.controller';
import { ExotelService } from './exotel.service'; import { ExotelService } from './exotel.service';
@Module({ @Module({
imports: [CallEventsModule], imports: [CallEventsModule],
controllers: [ExotelController], controllers: [ExotelController],
providers: [ExotelService], providers: [ExotelService],
exports: [ExotelService], exports: [ExotelService],
}) })
export class ExotelModule {} export class ExotelModule {}

View File

@@ -3,29 +3,34 @@ import type { ExotelWebhookPayload, CallEvent } from './exotel.types';
@Injectable() @Injectable()
export class ExotelService { export class ExotelService {
private readonly logger = new Logger(ExotelService.name); private readonly logger = new Logger(ExotelService.name);
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'
: 'ringing'; ? 'answered'
: event_details.event_type === 'terminal'
? 'ended'
: 'ringing';
const callEvent: CallEvent = { const callEvent: CallEvent = {
exotelCallSid: call_details.call_sid, exotelCallSid: call_details.call_sid,
eventType, eventType,
direction: call_details.direction, direction: call_details.direction,
callerPhone: call_details.customer_details?.number ?? '', callerPhone: call_details.customer_details?.number ?? '',
agentName: call_details.assigned_agent_details?.name ?? 'Unknown', agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
agentPhone: call_details.assigned_agent_details?.number ?? '', agentPhone: call_details.assigned_agent_details?.number ?? '',
duration: call_details.total_talk_time, duration: call_details.total_talk_time,
recordingUrl: call_details.recordings?.[0]?.url, recordingUrl: call_details.recordings?.[0]?.url,
callStatus: call_details.call_status, callStatus: call_details.call_status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`); this.logger.log(
return callEvent; `Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
} );
return callEvent;
}
} }

View File

@@ -1,35 +1,35 @@
// Exotel webhook payload (from their API docs) // Exotel webhook payload (from their API docs)
export type ExotelWebhookPayload = { export type ExotelWebhookPayload = {
event_details: { event_details: {
event_type: 'answered' | 'terminal'; event_type: 'answered' | 'terminal';
};
call_details: {
call_sid: string;
direction: 'inbound' | 'outbound';
call_status?: string;
total_talk_time?: number;
assigned_agent_details?: {
name: string;
number: string;
}; };
call_details: { customer_details?: {
call_sid: string; number: string;
direction: 'inbound' | 'outbound'; name?: string;
call_status?: string;
total_talk_time?: number;
assigned_agent_details?: {
name: string;
number: string;
};
customer_details?: {
number: string;
name?: string;
};
recordings?: { url: string }[];
}; };
recordings?: { url: string }[];
};
}; };
// Internal call event (normalized) // Internal call event (normalized)
export type CallEvent = { export type CallEvent = {
exotelCallSid: string; exotelCallSid: string;
eventType: 'ringing' | 'answered' | 'ended'; eventType: 'ringing' | 'answered' | 'ended';
direction: 'inbound' | 'outbound'; direction: 'inbound' | 'outbound';
callerPhone: string; callerPhone: string;
agentName: string; agentName: string;
agentPhone: string; agentPhone: string;
duration?: number; duration?: number;
recordingUrl?: string; recordingUrl?: string;
callStatus?: string; callStatus?: string;
timestamp: string; timestamp: string;
}; };

View File

@@ -1,45 +1,48 @@
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';
@Controller('graphql') @Controller('graphql')
export class GraphqlProxyController { export class GraphqlProxyController {
private readonly logger = new Logger(GraphqlProxyController.name); private readonly logger = new Logger(GraphqlProxyController.name);
private readonly graphqlUrl: string; private readonly graphqlUrl: string;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!; this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
}
@Post()
async proxy(@Req() req: Request, @Res() res: Response) {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new HttpException('Authorization header required', 401);
} }
@Post() try {
async proxy(@Req() req: Request, @Res() res: Response) { const response = await axios.post(this.graphqlUrl, req.body, {
const authHeader = req.headers.authorization; headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
});
if (!authHeader) { res.status(response.status).json(response.data);
throw new HttpException('Authorization header required', 401); } catch (error: any) {
} if (error.response) {
res.status(error.response.status).json(error.response.data);
try { } else {
const response = await axios.post( this.logger.error(`GraphQL proxy error: ${error.message}`);
this.graphqlUrl, throw new HttpException('Platform unreachable', 503);
req.body, }
{
headers: {
'Content-Type': 'application/json',
'Authorization': authHeader,
},
},
);
res.status(response.status).json(response.data);
} catch (error: any) {
if (error.response) {
res.status(error.response.status).json(error.response.data);
} else {
this.logger.error(`GraphQL proxy error: ${error.message}`);
throw new HttpException('Platform unreachable', 503);
}
}
} }
}
} }

View File

@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
import { GraphqlProxyController } from './graphql-proxy.controller'; import { GraphqlProxyController } from './graphql-proxy.controller';
@Module({ @Module({
controllers: [GraphqlProxyController], controllers: [GraphqlProxyController],
}) })
export class GraphqlProxyModule {} export class GraphqlProxyModule {}

View File

@@ -4,35 +4,39 @@ import axios from 'axios';
@Controller('api/health') @Controller('api/health')
export class HealthController { export class HealthController {
private readonly logger = new Logger(HealthController.name); private readonly logger = new Logger(HealthController.name);
private readonly graphqlUrl: string; private readonly graphqlUrl: string;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!; this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
}
@Get()
async check() {
let platformReachable = false;
let platformLatency = 0;
try {
const start = Date.now();
await axios.post(
this.graphqlUrl,
{ query: '{ __typename }' },
{
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
},
);
platformLatency = Date.now() - start;
platformReachable = true;
} catch {
platformReachable = false;
} }
@Get() return {
async check() { status: platformReachable ? 'ok' : 'degraded',
let platformReachable = false; platform: { reachable: platformReachable, latencyMs: platformLatency },
let platformLatency = 0; ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
try { };
const start = Date.now(); }
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
});
platformLatency = Date.now() - start;
platformReachable = true;
} catch {
platformReachable = false;
}
return {
status: platformReachable ? 'ok' : 'degraded',
platform: { reachable: platformReachable, latencyMs: platformLatency },
ozonetel: { configured: !!process.env.EXOTEL_API_KEY },
ai: { configured: !!process.env.ANTHROPIC_API_KEY },
};
}
} }

View File

@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
}) })
export class HealthModule {} export class HealthModule {}

View File

@@ -1,18 +1,38 @@
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);
app.enableCors({ const corsOrigins = config.get<string[]>('corsOrigins') || [
origin: config.get('corsOrigin'), 'http://localhost:5173',
credentials: true, ];
});
const port = config.get('port'); app.enableCors({
await app.listen(port); origin: corsOrigins,
console.log(`Helix Engage Server running on port ${port}`); 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(); bootstrap();

View File

@@ -3,49 +3,53 @@ import { ConfigService } from '@nestjs/config';
@Controller('kookoo') @Controller('kookoo')
export class KookooIvrController { export class KookooIvrController {
private readonly logger = new Logger(KookooIvrController.name); private readonly logger = new Logger(KookooIvrController.name);
private readonly sipId: string; private readonly sipId: string;
private readonly callerId: string; private readonly callerId: string;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590'; this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
this.callerId = process.env.OZONETEL_DID ?? '918041763265'; this.callerId = process.env.OZONETEL_DID ?? '918041763265';
} }
@Get('ivr') @Get('ivr')
@Header('Content-Type', 'application/xml') @Header('Content-Type', 'application/xml')
handleIvr(@Query() query: Record<string, any>): string { handleIvr(@Query() query: Record<string, any>): string {
const event = query.event ?? ''; const event = query.event ?? '';
const sid = query.sid ?? ''; const sid = query.sid ?? '';
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(
return `<?xml version="1.0" encoding="UTF-8"?> `Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
);
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>
</response>`; </response>`;
} }
// Conference event — user left with # // Conference event — user left with #
if (event === 'conference' || event === 'Conference') { if (event === 'conference' || event === 'Conference') {
this.logger.log(`Conference event: status=${status}`); this.logger.log(`Conference event: status=${status}`);
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<hangup/>
</response>`;
}
// Dial or Disconnect
this.logger.log(`Call ended: event=${event}`);
return `<?xml version="1.0" encoding="UTF-8"?>
<response> <response>
<hangup/> <hangup/>
</response>`; </response>`;
} }
// Dial or Disconnect
this.logger.log(`Call ended: event=${event}`);
return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<hangup/>
</response>`;
}
} }

View File

@@ -6,9 +6,9 @@ import { WorklistModule } from '../worklist/worklist.module';
import { PlatformModule } from '../platform/platform.module'; import { PlatformModule } from '../platform/platform.module';
@Module({ @Module({
imports: [PlatformModule, forwardRef(() => WorklistModule)], imports: [PlatformModule, forwardRef(() => WorklistModule)],
controllers: [OzonetelAgentController, KookooIvrController], controllers: [OzonetelAgentController, KookooIvrController],
providers: [OzonetelAgentService], providers: [OzonetelAgentService],
exports: [OzonetelAgentService], exports: [OzonetelAgentService],
}) })
export class OzonetelAgentModule {} export class OzonetelAgentModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,59 @@
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, UpdateLeadInput } from './platform.types'; import type {
LeadNode,
LeadActivityNode,
CreateCallInput,
CreateLeadActivityInput,
CreateLeadInput,
UpdateLeadInput,
} from './platform.types';
@Injectable() @Injectable()
export class PlatformGraphqlService { export class PlatformGraphqlService {
private readonly graphqlUrl: string; private readonly graphqlUrl: string;
private readonly apiKey: string; private readonly apiKey: string;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!; this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.apiKey = config.get<string>('platform.apiKey')!; this.apiKey = config.get<string>('platform.apiKey')!;
}
// Server-to-server query using API key
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
}
// Query using a passed-through auth header (user JWT)
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,
},
},
);
if (response.data.errors) {
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
} }
// Server-to-server query using API key return response.data.data;
async query<T>(query: string, variables?: Record<string, any>): Promise<T> { }
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
}
// Query using a passed-through auth header (user JWT) async findLeadByPhone(phone: string): Promise<LeadNode | null> {
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> { // Note: The exact filter syntax for PHONES fields depends on the platform
const response = await axios.post( // This queries leads and filters client-side by phone number
this.graphqlUrl, const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
{ query, variables }, `query FindLeads($first: Int) {
{
headers: {
'Content-Type': 'application/json',
'Authorization': authHeader,
},
},
);
if (response.data.errors) {
throw new Error(`GraphQL error: ${JSON.stringify(response.data.errors)}`);
}
return response.data.data;
}
async findLeadByPhone(phone: string): Promise<LeadNode | null> {
// Note: The exact filter syntax for PHONES fields depends on the platform
// This queries leads and filters client-side by phone number
const data = await this.query<{ leads: { edges: { node: LeadNode }[] } }>(
`query FindLeads($first: Int) {
leads(first: $first, orderBy: { createdAt: DescNullsLast }) { leads(first: $first, orderBy: { createdAt: DescNullsLast }) {
edges { edges {
node { node {
@@ -58,20 +69,26 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ first: 100 }, { first: 100 },
);
// Client-side phone matching (strip non-digits for comparison)
const normalizedPhone = phone.replace(/\D/g, '');
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
);
}
// Client-side phone matching (strip non-digits for comparison) async findLeadById(id: string): Promise<LeadNode | null> {
const normalizedPhone = phone.replace(/\D/g, ''); const data = await this.query<{ lead: LeadNode }>(
return data.leads.edges.find(edge => { `query FindLead($id: ID!) {
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;
}
async findLeadById(id: string): Promise<LeadNode | null> {
const data = await this.query<{ lead: LeadNode }>(
`query FindLead($id: ID!) {
lead(id: $id) { lead(id: $id) {
id createdAt id createdAt
contactName { firstName lastName } contactName { firstName lastName }
@@ -83,51 +100,68 @@ export class PlatformGraphqlService {
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} }
}`, }`,
{ id }, { id },
); );
return data.lead; return data.lead;
} }
async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> { async updateLead(id: string, input: UpdateLeadInput): Promise<LeadNode> {
const data = await this.query<{ updateLead: LeadNode }>( const data = await this.query<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { updateLead(id: $id, data: $data) {
id leadStatus aiSummary aiSuggestedAction id leadStatus aiSummary aiSuggestedAction
} }
}`, }`,
{ id, data: input }, { id, data: input },
); );
return data.updateLead; return data.updateLead;
} }
async createCall(input: CreateCallInput): Promise<{ id: string }> { async createCall(input: CreateCallInput): Promise<{ id: string }> {
const data = await this.query<{ createCall: { id: string } }>( const data = await this.query<{ createCall: { id: string } }>(
`mutation CreateCall($data: CallCreateInput!) { `mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id } createCall(data: $data) { id }
}`, }`,
{ data: input }, { data: input },
); );
return data.createCall; return data.createCall;
} }
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> { async createLeadActivity(
const data = await this.query<{ createLeadActivity: { id: string } }>( input: CreateLeadActivityInput,
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) { ): Promise<{ id: string }> {
const data = await this.query<{ createLeadActivity: { id: string } }>(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id } createLeadActivity(data: $data) { id }
}`, }`,
{ data: input }, { data: input },
); );
return data.createLeadActivity; return data.createLeadActivity;
} }
// --- Token passthrough versions (for user-driven requests) --- 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;
}
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> { // --- Token passthrough versions (for user-driven requests) ---
const normalizedPhone = phone.replace(/\D/g, '');
const last10 = normalizedPhone.slice(-10);
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>( async findLeadByPhoneWithToken(
`query FindLeads($first: Int) { 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 }[] };
}>(
`query FindLeads($first: Int) {
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) { leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
edges { edges {
node { node {
@@ -143,28 +177,43 @@ export class PlatformGraphqlService {
} }
} }
}`, }`,
{ first: 200 }, { first: 200 },
authHeader, authHeader,
); );
// Client-side phone matching // Client-side phone matching
return data.leads.edges.find(edge => { return (
const phones = edge.node.contactPhone ?? []; data.leads.edges.find((edge) => {
if (Array.isArray(phones)) { const phones = edge.node.contactPhone ?? [];
return phones.some((p: any) => { if (Array.isArray(phones)) {
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, ''); return phones.some((p: any) => {
return num.endsWith(last10) || last10.endsWith(num); const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
}); /\D/g,
} '',
// Handle single phone object );
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; });
} }
// Handle single phone object
const num = (
(phones as any).primaryPhoneNumber ??
(phones as any).number ??
''
).replace(/\D/g, '');
return num.endsWith(last10) || last10.endsWith(num);
})?.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,
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { 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 }]) { leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { edges {
node { node {
@@ -173,30 +222,39 @@ 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(
const data = await this.queryWithAuth<{ updateLead: LeadNode }>( id: string,
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { input: UpdateLeadInput,
authHeader: string,
): Promise<LeadNode> {
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { updateLead(id: $id, data: $data) {
id leadStatus aiSummary aiSuggestedAction id leadStatus aiSummary aiSuggestedAction
} }
}`, }`,
{ id, data: input }, { id, data: input },
authHeader, authHeader,
); );
return data.updateLead; return data.updateLead;
} }
// --- 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,
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) { 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 }) { leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
edges { edges {
node { node {
@@ -205,8 +263,8 @@ 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

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { PlatformGraphqlService } from './platform-graphql.service'; import { PlatformGraphqlService } from './platform-graphql.service';
@Module({ @Module({
providers: [PlatformGraphqlService], providers: [PlatformGraphqlService],
exports: [PlatformGraphqlService], exports: [PlatformGraphqlService],
}) })
export class PlatformModule {} export class PlatformModule {}

View File

@@ -63,6 +63,19 @@ export type CreateLeadActivityInput = {
leadId: string; 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 = { export type UpdateLeadInput = {
leadStatus?: string; leadStatus?: string;
lastContactedAt?: string; lastContactedAt?: string;

View File

@@ -4,91 +4,113 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@Controller('api/search') @Controller('api/search')
export class SearchController { export class SearchController {
private readonly logger = new Logger(SearchController.name); private readonly logger = new Logger(SearchController.name);
private readonly platformApiKey: string; private readonly platformApiKey: string;
constructor( constructor(
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly config: ConfigService, private readonly config: ConfigService,
) { ) {
this.platformApiKey = config.get<string>('platform.apiKey') ?? ''; this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
}
@Get()
async search(@Query('q') query?: string) {
if (!query || query.length < 2) {
return { leads: [], patients: [], appointments: [] };
} }
@Get() const authHeader = this.platformApiKey
async search(@Query('q') query?: string) { ? `Bearer ${this.platformApiKey}`
if (!query || query.length < 2) { : '';
return { leads: [], patients: [], appointments: [] }; if (!authHeader) {
} return { leads: [], patients: [], appointments: [] };
}
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : ''; this.logger.log(`Search: "${query}"`);
if (!authHeader) {
return { leads: [], patients: [], appointments: [] };
}
this.logger.log(`Search: "${query}"`); // Fetch all three in parallel, filter client-side for flexible matching
try {
// Fetch all three in parallel, filter client-side for flexible matching const [leadsResult, patientsResult, appointmentsResult] =
try { await Promise.all([
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([ this.platform
this.platform.queryWithAuth<any>( .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
`{ patients(first: 50) { edges { node { .queryWithAuth<any>(
`{ 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
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { .queryWithAuth<any>(
`{ 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();
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 =
const phone = l.contactPhone?.primaryPhoneNumber ?? ''; `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q); const phone = l.contactPhone?.primaryPhoneNumber ?? '';
}) return (
.slice(0, 5); name.includes(q) ||
phone.includes(q) ||
(l.name ?? '').toLowerCase().includes(q)
);
})
.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 =
const phone = p.phones?.primaryPhoneNumber ?? ''; `${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q); const phone = p.phones?.primaryPhoneNumber ?? '';
}) return (
.slice(0, 5); name.includes(q) ||
phone.includes(q) ||
(p.name ?? '').toLowerCase().includes(q)
);
})
.slice(0, 5);
const appointments = (appointmentsResult.appointments?.edges ?? []) const appointments = (appointmentsResult.appointments?.edges ?? [])
.map((e: any) => e.node) .map((e: any) => e.node)
.filter((a: any) => { .filter((a: any) => {
const doctor = (a.doctorName ?? '').toLowerCase(); const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase(); const dept = (a.department ?? '').toLowerCase();
return doctor.includes(q) || dept.includes(q); return doctor.includes(q) || dept.includes(q);
}) })
.slice(0, 5); .slice(0, 5);
return { leads, patients, appointments }; return { leads, patients, appointments };
} catch (err: any) { } catch (err: any) {
this.logger.error(`Search failed: ${err.message}`); this.logger.error(`Search failed: ${err.message}`);
return { leads: [], patients: [], appointments: [] }; return { leads: [], patients: [], appointments: [] };
}
} }
}
} }

View File

@@ -3,7 +3,7 @@ import { SearchController } from './search.controller';
import { PlatformModule } from '../platform/platform.module'; import { PlatformModule } from '../platform/platform.module';
@Module({ @Module({
imports: [PlatformModule], imports: [PlatformModule],
controllers: [SearchController], controllers: [SearchController],
}) })
export class SearchModule {} export class SearchModule {}

View File

@@ -4,89 +4,111 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@Controller('webhooks/kookoo') @Controller('webhooks/kookoo')
export class KookooCallbackController { export class KookooCallbackController {
private readonly logger = new Logger(KookooCallbackController.name); private readonly logger = new Logger(KookooCallbackController.name);
private readonly apiKey: string; private readonly apiKey: string;
constructor( constructor(
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
private readonly config: ConfigService, private readonly config: ConfigService,
) { ) {
this.apiKey = config.get<string>('platform.apiKey') ?? ''; this.apiKey = config.get<string>('platform.apiKey') ?? '';
}
@Post('callback')
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}`,
);
const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, '');
const status = params.status ?? 'unknown';
const duration = parseInt(params.duration ?? '0', 10);
const callerId = params.caller_id ?? '';
const startTime = params.start_time ?? null;
const endTime = params.end_time ?? null;
const sid = params.sid ?? null;
if (!phoneNumber) {
return { received: true, processed: false };
} }
@Post('callback') const callStatus = status === 'answered' ? 'COMPLETED' : 'MISSED';
async handleCallback(@Body() body: Record<string, any>, @Query() query: Record<string, any>) { const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
// 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}`);
const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, ''); if (!authHeader) {
const status = params.status ?? 'unknown'; this.logger.warn('No PLATFORM_API_KEY — cannot write call records');
const duration = parseInt(params.duration ?? '0', 10); return { received: true, processed: false };
const callerId = params.caller_id ?? '';
const startTime = params.start_time ?? null;
const endTime = params.end_time ?? null;
const sid = params.sid ?? null;
if (!phoneNumber) {
return { received: true, processed: false };
}
const callStatus = status === 'answered' ? 'COMPLETED' : 'MISSED';
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
if (!authHeader) {
this.logger.warn('No PLATFORM_API_KEY — cannot write call records');
return { received: true, processed: false };
}
try {
// Create call record
const callResult = await this.platform.queryWithAuth<any>(
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
{
data: {
name: `Outbound — ${phoneNumber}`,
direction: 'OUTBOUND',
callStatus,
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
startedAt: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
endedAt: endTime ? new Date(endTime).toISOString() : null,
durationSec: duration,
},
},
authHeader,
);
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>(
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } } } } }`,
undefined,
authHeader,
);
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, '');
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
});
if (matchedLead) {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } },
authHeader,
);
this.logger.log(`Linked call to lead ${matchedLead.id} (${matchedLead.name})`);
}
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}`);
return { received: true, processed: false };
}
} }
try {
// Create call record
const callResult = await this.platform.queryWithAuth<any>(
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
{
data: {
name: `Outbound — ${phoneNumber}`,
direction: 'OUTBOUND',
callStatus,
callerNumber: { primaryPhoneNumber: `+91${phoneNumber}` },
startedAt: startTime
? new Date(startTime).toISOString()
: new Date().toISOString(),
endedAt: endTime ? new Date(endTime).toISOString() : null,
durationSec: duration,
},
},
authHeader,
);
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>(
`{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } } } } }`,
undefined,
authHeader,
);
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,
'',
);
return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp);
});
if (matchedLead) {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: callResult.createCall.id, data: { leadId: matchedLead.id } },
authHeader,
);
this.logger.log(
`Linked call to lead ${matchedLead.id} (${matchedLead.name})`,
);
}
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}`,
);
return { received: true, processed: false };
}
}
} }