mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
Merge branch 'dev-main' into dev-kartik
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Server
|
||||||
|
PORT=4100
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||||
|
|
||||||
|
# Fortytwo Platform
|
||||||
|
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
||||||
|
PLATFORM_API_KEY=
|
||||||
|
|
||||||
|
# Exotel
|
||||||
|
EXOTEL_API_KEY=
|
||||||
|
EXOTEL_API_TOKEN=
|
||||||
|
EXOTEL_ACCOUNT_SID=
|
||||||
|
EXOTEL_SUBDOMAIN=api.exotel.com
|
||||||
|
EXOTEL_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# AI
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"@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",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
@@ -1884,6 +1885,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "http://localhost:4873/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "http://localhost:4873/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "http://localhost:4873/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -5284,6 +5291,15 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "http://localhost:4873/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "http://localhost:4873/co/-/co-4.6.0.tgz",
|
"resolved": "http://localhost:4873/co/-/co-4.6.0.tgz",
|
||||||
@@ -5605,6 +5621,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "http://localhost:4873/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "http://localhost:4873/depd/-/depd-2.0.0.tgz",
|
"resolved": "http://localhost:4873/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -7165,6 +7190,30 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "http://localhost:4873/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.5.1",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "http://localhost:4873/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "http://localhost:4873/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -8507,6 +8556,18 @@
|
|||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "http://localhost:4873/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "http://localhost:4873/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "http://localhost:4873/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "http://localhost:4873/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
@@ -9718,6 +9779,27 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "http://localhost:4873/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "http://localhost:4873/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "http://localhost:4873/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "http://localhost:4873/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
@@ -10258,6 +10340,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "http://localhost:4873/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "http://localhost:4873/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "http://localhost:4873/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@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",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { HealthModule } from './health/health.module';
|
|||||||
import { WorklistModule } from './worklist/worklist.module';
|
import { WorklistModule } from './worklist/worklist.module';
|
||||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||||
|
import { EmbedModule } from './embed/embed.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +32,8 @@ import { SearchModule } from './search/search.module';
|
|||||||
WorklistModule,
|
WorklistModule,
|
||||||
CallAssistModule,
|
CallAssistModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
SupervisorModule,
|
||||||
|
EmbedModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
78
src/auth/agent-config.service.ts
Normal file
78
src/auth/agent-config.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
export type AgentConfig = {
|
||||||
|
id: string;
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
sipExtension: string;
|
||||||
|
sipPassword: string;
|
||||||
|
campaignName: string;
|
||||||
|
sipUri: string;
|
||||||
|
sipWsServer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigService {
|
||||||
|
private readonly logger = new Logger(AgentConfigService.name);
|
||||||
|
private readonly cache = new Map<string, AgentConfig>();
|
||||||
|
private readonly sipDomain: string;
|
||||||
|
private readonly sipWsPort: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sipDomain = config.get<string>(
|
||||||
|
'sip.domain',
|
||||||
|
'blr-pub-rtc4.ozonetel.com',
|
||||||
|
);
|
||||||
|
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
const cached = this.cache.get(memberId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||||
|
id ozonetelagentid sipextension sippassword campaignname
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = data?.agents?.edges?.[0]?.node;
|
||||||
|
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
||||||
|
|
||||||
|
const agentConfig: AgentConfig = {
|
||||||
|
id: node.id,
|
||||||
|
ozonetelAgentId: node.ozonetelagentid,
|
||||||
|
sipExtension: node.sipextension,
|
||||||
|
sipPassword: node.sippassword ?? node.sipextension,
|
||||||
|
campaignName:
|
||||||
|
node.campaignname ??
|
||||||
|
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||||
|
'Inbound_918041763265',
|
||||||
|
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||||
|
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.set(memberId, agentConfig);
|
||||||
|
this.logger.log(
|
||||||
|
`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`,
|
||||||
|
);
|
||||||
|
return agentConfig;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFromCache(memberId: string): AgentConfig | null {
|
||||||
|
return this.cache.get(memberId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(memberId: string): void {
|
||||||
|
this.cache.delete(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
Req,
|
||||||
|
Logger,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -13,6 +24,8 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private ozonetelAgent: OzonetelAgentService,
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
private agentConfigService: AgentConfigService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain =
|
this.workspaceSubdomain =
|
||||||
@@ -22,7 +35,10 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() body: { email: string; password: string }) {
|
async login(
|
||||||
|
@Body() body: { email: string; password: string },
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
this.logger.log(`Login attempt for ${body.email}`);
|
this.logger.log(`Login attempt for ${body.email}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -128,18 +144,63 @@ export class AuthController {
|
|||||||
`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`,
|
`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-login Ozonetel agent for CC agents (fire and forget)
|
// Multi-agent: resolve agent config + session lock for CC agents
|
||||||
|
let agentConfigResponse: any = undefined;
|
||||||
|
|
||||||
if (appRole === 'cc-agent') {
|
if (appRole === 'cc-agent') {
|
||||||
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
|
const memberId = workspaceMember?.id;
|
||||||
|
if (!memberId)
|
||||||
|
throw new HttpException('Workspace member not found', 400);
|
||||||
|
|
||||||
|
const agentConfig =
|
||||||
|
await this.agentConfigService.getByMemberId(memberId);
|
||||||
|
if (!agentConfig) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Agent account not configured. Contact administrator.',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate login — strict: one device only
|
||||||
|
const clientIp =
|
||||||
|
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ??
|
||||||
|
req.ip ??
|
||||||
|
'unknown';
|
||||||
|
const existingSession = await this.sessionService.getSession(
|
||||||
|
agentConfig.ozonetelAgentId,
|
||||||
|
);
|
||||||
|
if (existingSession) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`,
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
`You are already logged in from another device (${existingSession.ip}). Please log out there first.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock session in Redis with IP
|
||||||
|
await this.sessionService.lockSession(
|
||||||
|
agentConfig.ozonetelAgentId,
|
||||||
|
memberId,
|
||||||
|
clientIp,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force-refresh Ozonetel API token on login
|
||||||
|
this.ozonetelAgent.refreshToken().catch((err) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`Ozonetel token refresh on login failed: ${err.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login to Ozonetel with agent-specific credentials
|
||||||
const ozAgentPassword =
|
const ozAgentPassword =
|
||||||
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||||
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
|
|
||||||
|
|
||||||
this.ozonetelAgent
|
this.ozonetelAgent
|
||||||
.loginAgent({
|
.loginAgent({
|
||||||
agentId: ozAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: ozAgentPassword,
|
password: ozAgentPassword,
|
||||||
phoneNumber: ozSipId,
|
phoneNumber: agentConfig.sipExtension,
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -147,6 +208,19 @@ export class AuthController {
|
|||||||
`Ozonetel agent login failed (non-blocking): ${err.message}`,
|
`Ozonetel agent login failed (non-blocking): ${err.message}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
agentConfigResponse = {
|
||||||
|
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||||
|
sipExtension: agentConfig.sipExtension,
|
||||||
|
sipPassword: agentConfig.sipPassword,
|
||||||
|
sipUri: agentConfig.sipUri,
|
||||||
|
sipWsServer: agentConfig.sipWsServer,
|
||||||
|
campaignName: agentConfig.campaignName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -161,6 +235,7 @@ export class AuthController {
|
|||||||
role: appRole,
|
role: appRole,
|
||||||
platformRoles: roleLabels,
|
platformRoles: roleLabels,
|
||||||
},
|
},
|
||||||
|
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -214,4 +289,76 @@ export class AuthController {
|
|||||||
throw new HttpException('Token refresh failed', 401);
|
throw new HttpException('Token refresh failed', 401);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(
|
||||||
|
this.graphqlUrl,
|
||||||
|
{
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
if (!memberId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
|
this.ozonetelAgent
|
||||||
|
.logoutAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(`Ozonetel logout failed: ${err.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('heartbeat')
|
||||||
|
async heartbeat(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(
|
||||||
|
this.graphqlUrl,
|
||||||
|
{
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
const agentConfig = memberId
|
||||||
|
? this.agentConfigService.getFromCache(memberId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OzonetelAgentModule],
|
imports: [OzonetelAgentModule, PlatformModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
providers: [SessionService, AgentConfigService],
|
||||||
|
exports: [SessionService, AgentConfigService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
65
src/auth/session.service.ts
Normal file
65
src/auth/session.service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const SESSION_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionService.name);
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
|
this.redis = new Redis(url);
|
||||||
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
|
this.redis.on('error', (err) =>
|
||||||
|
this.logger.error(`Redis error: ${err.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(agentId: string): string {
|
||||||
|
return `agent:session:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lockSession(
|
||||||
|
agentId: string,
|
||||||
|
memberId: string,
|
||||||
|
ip?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const value = JSON.stringify({
|
||||||
|
memberId,
|
||||||
|
ip: ip ?? 'unknown',
|
||||||
|
lockedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||||
|
const raw = await this.redis.get(this.key(agentId));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Legacy format — just memberId string
|
||||||
|
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||||
|
const session = await this.getSession(agentId);
|
||||||
|
return session ? session.memberId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(agentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
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')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0),
|
||||||
platform: {
|
platform: {
|
||||||
graphqlUrl:
|
graphqlUrl:
|
||||||
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||||
@@ -13,6 +16,13 @@ export default () => ({
|
|||||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
||||||
},
|
},
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||||
|
},
|
||||||
missedQueue: {
|
missedQueue: {
|
||||||
pollIntervalMs: parseInt(
|
pollIntervalMs: parseInt(
|
||||||
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
||||||
|
|||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,15 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
|
const corsOrigins = config.get<string[]>('corsOrigins') || [
|
||||||
|
'http://localhost:5173',
|
||||||
|
];
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: config.get('corsOrigin'),
|
origin: corsOrigins,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export class OzonetelAgentService {
|
|||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<string> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||||
this.logger.log('Generating CloudAgent API token');
|
this.logger.log('Generating CloudAgent API token');
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ export class OzonetelAgentService {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
this.cachedToken = data.token;
|
this.cachedToken = data.token;
|
||||||
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
this.tokenExpiry = Date.now() + 10 * 60 * 1000; // 10 min cache (Ozonetel expires in ~15 min)
|
||||||
this.logger.log('CloudAgent token generated successfully');
|
this.logger.log('CloudAgent token generated successfully');
|
||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,11 @@ export class OzonetelAgentService {
|
|||||||
throw new Error(data.message ?? 'Token generation failed');
|
throw new Error(data.message ?? 'Token generation failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateToken(): void {
|
||||||
|
this.cachedToken = null;
|
||||||
|
this.tokenExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
async loginAgent(params: {
|
async loginAgent(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -81,20 +90,48 @@ export class OzonetelAgentService {
|
|||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// "already logged in" is not a real error — treat as success
|
// "already logged in" — force logout + re-login to refresh SIP phone mapping
|
||||||
if (
|
if (
|
||||||
data.status === 'error' &&
|
data.status === 'error' &&
|
||||||
data.message?.includes('already logged in')
|
data.message?.includes('already logged in')
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Agent ${params.agentId} already logged in — treating as success`,
|
`Agent ${params.agentId} already logged in — forcing logout + re-login`,
|
||||||
);
|
);
|
||||||
return { status: 'success', message: data.message };
|
try {
|
||||||
|
await this.logoutAgent({
|
||||||
|
agentId: params.agentId,
|
||||||
|
password: params.password,
|
||||||
|
});
|
||||||
|
const retryResponse = await axios.post(
|
||||||
|
url,
|
||||||
|
new URLSearchParams({
|
||||||
|
userName: this.accountId,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
phoneNumber: params.phoneNumber,
|
||||||
|
action: 'login',
|
||||||
|
mode: params.mode ?? 'blended',
|
||||||
|
state: 'Ready',
|
||||||
|
}).toString(),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
auth: { username: params.agentId, password: params.password },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Agent re-login response: ${JSON.stringify(retryResponse.data)}`,
|
||||||
|
);
|
||||||
|
return retryResponse.data;
|
||||||
|
} catch (retryErr: any) {
|
||||||
|
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
|
||||||
|
return { status: 'success', message: 'Re-login attempted' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent login failed: ${error.message}`);
|
this.logger.error(`Agent login failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -129,10 +166,10 @@ export class OzonetelAgentService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
const responseData = error?.response?.data
|
const responseData = error?.response?.data
|
||||||
? JSON.stringify(error.response.data)
|
? JSON.stringify(error.response.data)
|
||||||
: '';
|
: '';
|
||||||
@@ -362,6 +399,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Abandon calls failed: ${error.message}`);
|
this.logger.error(`Abandon calls failed: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -458,6 +496,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent summary failed: ${error.message}`);
|
this.logger.error(`Agent summary failed: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -487,6 +526,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`AHT failed: ${error.message}`);
|
this.logger.error(`AHT failed: ${error.message}`);
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
}
|
}
|
||||||
@@ -526,6 +566,7 @@ export class OzonetelAgentService {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent logout failed: ${error.message}`);
|
this.logger.error(`Agent logout failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
LeadActivityNode,
|
LeadActivityNode,
|
||||||
CreateCallInput,
|
CreateCallInput,
|
||||||
CreateLeadActivityInput,
|
CreateLeadActivityInput,
|
||||||
|
CreateLeadInput,
|
||||||
UpdateLeadInput,
|
UpdateLeadInput,
|
||||||
} from './platform.types';
|
} from './platform.types';
|
||||||
|
|
||||||
@@ -138,6 +139,16 @@ export class PlatformGraphqlService {
|
|||||||
return data.createLeadActivity;
|
return data.createLeadActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createLead(input: CreateLeadInput): Promise<{ id: string }> {
|
||||||
|
const data = await this.query<{ createLead: { id: string } }>(
|
||||||
|
`mutation CreateLead($data: LeadCreateInput!) {
|
||||||
|
createLead(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{ data: input },
|
||||||
|
);
|
||||||
|
return data.createLead;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Token passthrough versions (for user-driven requests) ---
|
// --- Token passthrough versions (for user-driven requests) ---
|
||||||
|
|
||||||
async findLeadByPhoneWithToken(
|
async findLeadByPhoneWithToken(
|
||||||
|
|||||||
@@ -62,6 +62,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;
|
||||||
|
|||||||
41
src/supervisor/supervisor.controller.ts
Normal file
41
src/supervisor/supervisor.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Controller('api/supervisor')
|
||||||
|
export class SupervisorController {
|
||||||
|
private readonly logger = new Logger(SupervisorController.name);
|
||||||
|
|
||||||
|
constructor(private readonly supervisor: SupervisorService) {}
|
||||||
|
|
||||||
|
@Get('active-calls')
|
||||||
|
getActiveCalls() {
|
||||||
|
return this.supervisor.getActiveCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('team-performance')
|
||||||
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
this.logger.log(`Team performance: date=${targetDate}`);
|
||||||
|
return this.supervisor.getTeamPerformance(targetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('call-event')
|
||||||
|
handleCallEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(
|
||||||
|
`Call event: ${event.action} ucid=${event.ucid ?? event.monitorUCID} agent=${event.agent_id ?? event.agentID}`,
|
||||||
|
);
|
||||||
|
this.supervisor.handleCallEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-event')
|
||||||
|
handleAgentEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(
|
||||||
|
`Agent event: ${event.action} agent=${event.agentId ?? event.agent_id}`,
|
||||||
|
);
|
||||||
|
this.supervisor.handleAgentEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/supervisor/supervisor.module.ts
Normal file
12
src/supervisor/supervisor.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule],
|
||||||
|
controllers: [SupervisorController],
|
||||||
|
providers: [SupervisorService],
|
||||||
|
})
|
||||||
|
export class SupervisorModule {}
|
||||||
98
src/supervisor/supervisor.service.ts
Normal file
98
src/supervisor/supervisor.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
type ActiveCall = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerNumber: string;
|
||||||
|
callType: string;
|
||||||
|
startTime: string;
|
||||||
|
status: 'active' | 'on-hold';
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupervisorService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private ozonetel: OzonetelAgentService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Supervisor service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallEvent(event: any) {
|
||||||
|
const action = event.action;
|
||||||
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
|
const callType = event.call_type ?? event.Type;
|
||||||
|
const eventTime =
|
||||||
|
event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
|
||||||
|
if (!ucid) return;
|
||||||
|
|
||||||
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
|
this.activeCalls.set(ucid, {
|
||||||
|
ucid,
|
||||||
|
agentId,
|
||||||
|
callerNumber,
|
||||||
|
callType,
|
||||||
|
startTime: eventTime,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||||
|
} else if (action === 'Disconnect') {
|
||||||
|
this.activeCalls.delete(ucid);
|
||||||
|
this.logger.log(`Call ended: ${ucid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAgentEvent(event: any) {
|
||||||
|
this.logger.log(
|
||||||
|
`Agent event: ${event.agentId ?? event.agent_id} → ${event.action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
return Array.from(this.activeCalls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
|
// Get all agents from platform
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 20) { edges { node {
|
||||||
|
id name ozonetelagentid npsscore
|
||||||
|
maxidleminutes minnpsthreshold minconversionpercent
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch Ozonetel time summary per agent
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
agents.map(async (agent: any) => {
|
||||||
|
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||||
|
try {
|
||||||
|
const summary = await this.ozonetel.getAgentSummary(
|
||||||
|
agent.ozonetelagentid,
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
return { ...agent, timeBreakdown: summary };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get summary for ${agent.ozonetelagentid}: ${err}`,
|
||||||
|
);
|
||||||
|
return { ...agent, timeBreakdown: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { date, agents: summaries };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user