mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Compare commits
9 Commits
44f1ec36e1
...
dev-kartik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603ec7c612 | ||
|
|
05eb7a326e | ||
|
|
bb20f5102a | ||
|
|
09c7930b52 | ||
|
|
e912b982df | ||
|
|
c80dddee0f | ||
|
|
bb46549a4d | ||
|
|
33ec8f5db8 | ||
|
|
a1157ab4c1 |
32
.claudeignore
Normal file
32
.claudeignore
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Test coverage output
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Generated type declarations
|
||||||
|
**/*.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# E2E test fixtures (keep unit tests)
|
||||||
|
test/
|
||||||
|
|
||||||
|
# Environment secrets — never read
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Server
|
# 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -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
6909
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 '';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
9
src/embed/embed.module.ts
Normal file
9
src/embed/embed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { LeadEmbedController } from './lead-embed.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
controllers: [LeadEmbedController],
|
||||||
|
})
|
||||||
|
export class EmbedModule {}
|
||||||
193
src/embed/lead-embed.controller.ts
Normal file
193
src/embed/lead-embed.controller.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Controller('embed/leads')
|
||||||
|
export class LeadEmbedController {
|
||||||
|
private readonly logger = new Logger(LeadEmbedController.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
async handleLeadCreation(@Body() body: Record<string, any>) {
|
||||||
|
console.log('Lead creation from embed received:', body);
|
||||||
|
this.logger.log(
|
||||||
|
`Lead creation from embed received: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
|
if (!authHeader) {
|
||||||
|
this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead');
|
||||||
|
throw new HttpException('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leadData = this.mapIncomingDataToLead(body);
|
||||||
|
|
||||||
|
if (!leadData.contactPhone && !leadData.contactEmail) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Either contact phone or email is required',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: leadData },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadId = result.createLead.id;
|
||||||
|
this.logger.log(`Lead created successfully: ${leadId}`);
|
||||||
|
|
||||||
|
if (body.notes || body.type) {
|
||||||
|
await this.createInitialActivity(leadId, body, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
leadId,
|
||||||
|
message: 'Lead created successfully',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
const responseData = error?.response?.data
|
||||||
|
? JSON.stringify(error.response.data)
|
||||||
|
: '';
|
||||||
|
this.logger.error(
|
||||||
|
`Lead creation failed: ${error.message} ${responseData}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
error.message || 'Lead creation failed',
|
||||||
|
error.response?.status || 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapIncomingDataToLead(
|
||||||
|
body: Record<string, any>,
|
||||||
|
): Record<string, any> {
|
||||||
|
const leadData: Record<string, any> = {};
|
||||||
|
|
||||||
|
const contactName = body.contact_name || body.contactName || 'Unknown';
|
||||||
|
const nameParts = contactName.split(' ');
|
||||||
|
const firstName = nameParts[0] || 'Unknown';
|
||||||
|
const lastName = nameParts.slice(1).join(' ');
|
||||||
|
|
||||||
|
leadData.name = contactName;
|
||||||
|
leadData.contactName = {
|
||||||
|
firstName,
|
||||||
|
lastName: lastName || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.contact_phone || body.contactPhone) {
|
||||||
|
const phone = body.contact_phone || body.contactPhone;
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
leadData.contactPhone = {
|
||||||
|
primaryPhoneNumber: cleanPhone.startsWith('91')
|
||||||
|
? `+${cleanPhone}`
|
||||||
|
: `+91${cleanPhone}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.contact_email || body.contactEmail) {
|
||||||
|
leadData.contactEmail = {
|
||||||
|
primaryEmail: body.contact_email || body.contactEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
leadData.source = body.source || 'WEBSITE';
|
||||||
|
leadData.status = body.lead_status || body.status || 'NEW';
|
||||||
|
|
||||||
|
const interestedService = this.mapInterestedService(body);
|
||||||
|
if (interestedService) {
|
||||||
|
leadData.interestedService = interestedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.assigned_agent || body.assignedAgent) {
|
||||||
|
leadData.assignedAgent = body.assigned_agent || body.assignedAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.campaign_id || body.campaignId) {
|
||||||
|
leadData.campaignId = body.campaign_id || body.campaignId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapInterestedService(body: Record<string, any>): string | null {
|
||||||
|
const type = body.type || body.interested_service || body.interestedService;
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return body.department || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap: Record<string, string> = {
|
||||||
|
consultation: 'Appointment',
|
||||||
|
follow_up: 'Appointment',
|
||||||
|
procedure: 'Appointment',
|
||||||
|
emergency: 'Appointment',
|
||||||
|
general_enquiry: 'General Enquiry',
|
||||||
|
general: 'General Enquiry',
|
||||||
|
};
|
||||||
|
|
||||||
|
return serviceMap[type.toLowerCase()] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createInitialActivity(
|
||||||
|
leadId: string,
|
||||||
|
body: Record<string, any>,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activityType =
|
||||||
|
body.type === 'consultation' || body.type === 'appointment'
|
||||||
|
? 'APPOINTMENT_BOOKED'
|
||||||
|
: 'CALL_RECEIVED';
|
||||||
|
|
||||||
|
let summary = 'Lead submitted via web form';
|
||||||
|
if (body.type) {
|
||||||
|
summary = `${body.type.replace(/_/g, ' ')} requested`;
|
||||||
|
}
|
||||||
|
if (body.department) {
|
||||||
|
summary += ` - ${body.department}`;
|
||||||
|
}
|
||||||
|
if (body.title) {
|
||||||
|
summary += ` (from ${body.title})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: summary.substring(0, 80),
|
||||||
|
activityType,
|
||||||
|
summary,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
performedBy: 'System',
|
||||||
|
channel: 'PHONE',
|
||||||
|
leadId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Initial activity created for lead ${leadId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorDetails = error?.response?.data
|
||||||
|
? JSON.stringify(error.response.data)
|
||||||
|
: error.message;
|
||||||
|
this.logger.error(`Failed to create initial activity: ${errorDetails}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
38
src/main.ts
38
src/main.ts
@@ -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();
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user