From e4a24feedb6e36949b31c94f6053fa30ad0ee6f4 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 21:24:32 +0530 Subject: [PATCH 1/8] feat: multi-agent SIP with Redis session lockout - SessionService: Redis-backed session lock/unlock/refresh with 1hr TTL - AgentConfigService: queries Agent entity, caches per-member config - Auth login: resolves agent config, locks Redis session, returns SIP credentials - Auth logout: unlocks Redis session, Ozonetel logout, clears cache - Auth heartbeat: refreshes Redis TTL every 5 minutes - Added ioredis dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 88 ++++++++++++++++++++++++++ package.json | 1 + src/auth/agent-config.service.ts | 70 +++++++++++++++++++++ src/auth/auth.controller.ts | 103 ++++++++++++++++++++++++++++--- src/auth/auth.module.ts | 7 ++- src/auth/session.service.ts | 40 ++++++++++++ src/config/configuration.ts | 7 +++ 7 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/auth/agent-config.service.ts create mode 100644 src/auth/session.service.ts diff --git a/package-lock.json b/package-lock.json index 4e2dfd5..05c3d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/websockets": "^11.1.17", "ai": "^6.0.116", "axios": "^1.13.6", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3" @@ -1881,6 +1882,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": { "version": "8.0.2", "resolved": "http://localhost:4873/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5153,6 +5160,15 @@ "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": { "version": "4.6.0", "resolved": "http://localhost:4873/co/-/co-4.6.0.tgz", @@ -5467,6 +5483,15 @@ "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": { "version": "2.0.0", "resolved": "http://localhost:4873/depd/-/depd-2.0.0.tgz", @@ -6978,6 +7003,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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": { "version": "1.9.1", "resolved": "http://localhost:4873/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8171,6 +8220,18 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "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": { "version": "4.1.2", "resolved": "http://localhost:4873/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9166,6 +9227,27 @@ "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": { "version": "0.2.2", "resolved": "http://localhost:4873/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9653,6 +9735,12 @@ "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": { "version": "2.0.2", "resolved": "http://localhost:4873/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index a61c058..8b6f103 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/websockets": "^11.1.17", "ai": "^6.0.116", "axios": "^1.13.6", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3" diff --git a/src/auth/agent-config.service.ts b/src/auth/agent-config.service.ts new file mode 100644 index 0000000..47f8423 --- /dev/null +++ b/src/auth/agent-config.service.ts @@ -0,0 +1,70 @@ +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(); + private readonly sipDomain: string; + private readonly sipWsPort: string; + + constructor( + private platform: PlatformGraphqlService, + private config: ConfigService, + ) { + this.sipDomain = config.get('sip.domain', 'blr-pub-rtc4.ozonetel.com'); + this.sipWsPort = config.get('sip.wsPort', '444'); + } + + async getByMemberId(memberId: string): Promise { + const cached = this.cache.get(memberId); + if (cached) return cached; + + try { + const data = await this.platform.query( + `{ 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); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bce2340..eff3f53 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common'; +import { Controller, Post, Body, Headers, Logger, HttpException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; +import { SessionService } from './session.service'; +import { AgentConfigService } from './agent-config.service'; @Controller('auth') export class AuthController { @@ -13,6 +15,8 @@ export class AuthController { constructor( private config: ConfigService, private ozonetelAgent: OzonetelAgentService, + private sessionService: SessionService, + private agentConfigService: AgentConfigService, ) { this.graphqlUrl = config.get('platform.graphqlUrl')!; this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev'; @@ -111,20 +115,48 @@ export class AuthController { this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`); - // Auto-login Ozonetel agent for CC agents (fire and forget) - if (appRole === 'cc-agent') { - const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3'; - const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; - const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814'; + // Multi-agent: resolve agent config + session lock for CC agents + let agentConfigResponse: any = undefined; + if (appRole === 'cc-agent') { + 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 + const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId); + if (existingSession && existingSession !== memberId) { + throw new HttpException('You are already logged in on another device. Please log out there first.', 409); + } + + // Lock session in Redis + await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId); + + // Login to Ozonetel with agent-specific credentials + const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; this.ozonetelAgent.loginAgent({ - agentId: ozAgentId, + agentId: agentConfig.ozonetelAgentId, password: ozAgentPassword, - phoneNumber: ozSipId, + phoneNumber: agentConfig.sipExtension, mode: 'blended', }).catch(err => { this.logger.warn(`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 { @@ -139,6 +171,7 @@ export class AuthController { role: appRole, platformRoles: roleLabels, }, + ...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}), }; } catch (error) { if (error instanceof HttpException) throw error; @@ -186,4 +219,58 @@ export class AuthController { 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' }; + } + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 76842f5..3ff25ea 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; 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({ - imports: [OzonetelAgentModule], + imports: [OzonetelAgentModule, PlatformModule], controllers: [AuthController], + providers: [SessionService, AgentConfigService], + exports: [SessionService, AgentConfigService], }) export class AuthModule {} diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts new file mode 100644 index 0000000..f1aa198 --- /dev/null +++ b/src/auth/session.service.ts @@ -0,0 +1,40 @@ +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('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): Promise { + await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL); + } + + async isSessionLocked(agentId: string): Promise { + return this.redis.get(this.key(agentId)); + } + + async refreshSession(agentId: string): Promise { + await this.redis.expire(this.key(agentId), SESSION_TTL); + } + + async unlockSession(agentId: string): Promise { + await this.redis.del(this.key(agentId)); + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 638f5fa..48ad069 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -12,6 +12,13 @@ export default () => ({ subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com', 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: { pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), }, From 77c5335955fdae041867b1f0e5a3f4f9ffe3467b Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 21:44:56 +0530 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20strict=20duplicate=20login=20lockout?= =?UTF-8?q?=20=E2=80=94=20one=20device=20per=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block any login attempt when a session exists, regardless of user identity. Same user on second device is blocked until logout or TTL expiry. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/auth/auth.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index eff3f53..ba5837d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -127,9 +127,9 @@ export class AuthController { throw new HttpException('Agent account not configured. Contact administrator.', 403); } - // Check for duplicate login + // Check for duplicate login — strict: one device only const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId); - if (existingSession && existingSession !== memberId) { + if (existingSession) { throw new HttpException('You are already logged in on another device. Please log out there first.', 409); } From a35a7d70bf58fe4d2329b3c0fc487bb90b9ea1f7 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:21:13 +0530 Subject: [PATCH 3/8] feat: session lock stores IP + timestamp for debugging - SessionService stores JSON { memberId, ip, lockedAt } instead of plain memberId - Auth controller extracts client IP from x-forwarded-for header - Lockout error message includes IP of blocking device Co-Authored-By: Claude Opus 4.6 (1M context) --- src/auth/auth.controller.ts | 15 +++++++++------ src/auth/session.service.ts | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index ba5837d..8e3494a 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Post, Body, Headers, 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 axios from 'axios'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; @@ -24,7 +25,7 @@ export class AuthController { } @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}`); try { @@ -128,13 +129,15 @@ export class AuthController { } // Check for duplicate login — strict: one device only - const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId); + 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) { - throw new HttpException('You are already logged in on another device. Please log out there first.', 409); + 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 - await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId); + // Lock session in Redis with IP + await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp); // Login to Ozonetel with agent-specific credentials const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index f1aa198..b695002 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -22,12 +22,25 @@ export class SessionService implements OnModuleInit { return `agent:session:${agentId}`; } - async lockSession(agentId: string, memberId: string): Promise { - await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL); + async lockSession(agentId: string, memberId: string, ip?: string): Promise { + 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 { - return this.redis.get(this.key(agentId)); + const session = await this.getSession(agentId); + return session ? session.memberId : null; } async refreshSession(agentId: string): Promise { From 2e4f97ff1a6223d1c2e6351840d8a0fa0f53baaa Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:53:49 +0530 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20supervisor=20module=20=E2=80=94=20t?= =?UTF-8?q?eam=20performance=20+=20active=20calls=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SupervisorService: aggregates Ozonetel agent summary across all agents, tracks active calls from real-time events - GET /api/supervisor/team-performance — per-agent time breakdown + thresholds - GET /api/supervisor/active-calls — current active call map - POST /api/supervisor/call-event — Ozonetel event webhook - POST /api/supervisor/agent-event — Ozonetel agent event webhook Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.module.ts | 2 + src/supervisor/supervisor.controller.ts | 37 +++++++++++ src/supervisor/supervisor.module.ts | 12 ++++ src/supervisor/supervisor.service.ts | 86 +++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/supervisor/supervisor.controller.ts create mode 100644 src/supervisor/supervisor.module.ts create mode 100644 src/supervisor/supervisor.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5c55f4a..603bf32 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { HealthModule } from './health/health.module'; import { WorklistModule } from './worklist/worklist.module'; import { CallAssistModule } from './call-assist/call-assist.module'; import { SearchModule } from './search/search.module'; +import { SupervisorModule } from './supervisor/supervisor.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { SearchModule } from './search/search.module'; WorklistModule, CallAssistModule, SearchModule, + SupervisorModule, ], }) export class AppModule {} diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts new file mode 100644 index 0000000..03e0e2d --- /dev/null +++ b/src/supervisor/supervisor.controller.ts @@ -0,0 +1,37 @@ +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 }; + } +} diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts new file mode 100644 index 0000000..f0ffad1 --- /dev/null +++ b/src/supervisor/supervisor.module.ts @@ -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 {} diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts new file mode 100644 index 0000000..56facea --- /dev/null +++ b/src/supervisor/supervisor.service.ts @@ -0,0 +1,86 @@ +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(); + + 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 { + // Get all agents from platform + const agentData = await this.platform.query( + `{ 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 }; + } +} From fd08a5d5dbde198f647ef3d8391dad6eac37c565 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 15:22:19 +0530 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20Ozonetel=20token=20=E2=80=94=2010min?= =?UTF-8?q?=20cache,=20invalidate=20on=20401,=20refresh=20on=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduced token cache from 55min to 10min (Ozonetel expires in ~15min) - All API methods invalidate cached token on 401 response - Force-refresh token on CC agent login - Removed unused withTokenRetry wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- src/auth/auth.controller.ts | 5 +++++ src/ozonetel/ozonetel-agent.service.ts | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 8e3494a..7c931b6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -139,6 +139,11 @@ export class AuthController { // 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 = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; this.ozonetelAgent.loginAgent({ diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 8267b1d..789d6e3 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -22,6 +22,10 @@ export class OzonetelAgentService { return this.cachedToken; } + return this.refreshToken(); + } + + async refreshToken(): Promise { const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`; this.logger.log('Generating CloudAgent API token'); @@ -32,7 +36,7 @@ export class OzonetelAgentService { const data = response.data; if (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'); return data.token; } @@ -40,6 +44,12 @@ export class OzonetelAgentService { throw new Error(data.message ?? 'Token generation failed'); } + private invalidateToken(): void { + this.cachedToken = null; + this.tokenExpiry = 0; + } + + async loginAgent(params: { agentId: string; password: string; @@ -83,6 +93,7 @@ export class OzonetelAgentService { this.logger.log(`Agent login response: ${JSON.stringify(data)}`); return data; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Agent login failed: ${error.message}`); throw error; } @@ -111,10 +122,10 @@ export class OzonetelAgentService { 'Content-Type': 'application/json', }, }); - this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`); return response.data; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; this.logger.error(`Manual dial failed: ${error.message} ${responseData}`); throw error; @@ -304,6 +315,7 @@ export class OzonetelAgentService { } return []; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Abandon calls failed: ${error.message}`); return []; } @@ -393,6 +405,7 @@ export class OzonetelAgentService { } return null; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Agent summary failed: ${error.message}`); return null; } @@ -422,6 +435,7 @@ export class OzonetelAgentService { } return '00:00:00'; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`AHT failed: ${error.message}`); return '00:00:00'; } @@ -459,6 +473,7 @@ export class OzonetelAgentService { this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`); return response.data; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Agent logout failed: ${error.message}`); throw error; } From d3331e56c0b9d36bbf5db1d3f7f3f9aefef79238 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 18:49:26 +0530 Subject: [PATCH 6/8] fix: Ozonetel token 10min cache + invalidate on 401 + force re-login on already logged in - Token cache reduced from 55min to 10min (Ozonetel expires in ~15min) - All API methods invalidate cached token on 401 - loginAgent forces logout + re-login when "already logged in" to refresh SIP phone mapping Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.service.ts | 28 +++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 789d6e3..7c661d7 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -84,10 +84,32 @@ export class OzonetelAgentService { 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 (data.status === 'error' && data.message?.includes('already logged in')) { - this.logger.log(`Agent ${params.agentId} already logged in — treating as success`); - return { status: 'success', message: data.message }; + this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`); + 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)}`); From e912b982df2c896346179f9265bd5e1b614133bb Mon Sep 17 00:00:00 2001 From: moulichand16 Date: Fri, 27 Mar 2026 10:53:20 +0530 Subject: [PATCH 7/8] added script forms --- src/app.module.ts | 2 + src/embed/embed-cors.middleware.ts | 27 ++++ src/embed/embed.module.ts | 18 +++ src/embed/lead-embed.controller.ts | 176 +++++++++++++++++++++++ src/main.ts | 1 + src/platform/platform-graphql.service.ts | 12 +- src/platform/platform.types.ts | 13 ++ 7 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/embed/embed-cors.middleware.ts create mode 100644 src/embed/embed.module.ts create mode 100644 src/embed/lead-embed.controller.ts diff --git a/src/app.module.ts b/src/app.module.ts index 603bf32..36aaada 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { WorklistModule } from './worklist/worklist.module'; import { CallAssistModule } from './call-assist/call-assist.module'; import { SearchModule } from './search/search.module'; import { SupervisorModule } from './supervisor/supervisor.module'; +import { EmbedModule } from './embed/embed.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { SupervisorModule } from './supervisor/supervisor.module'; CallAssistModule, SearchModule, SupervisorModule, + EmbedModule, ], }) export class AppModule {} diff --git a/src/embed/embed-cors.middleware.ts b/src/embed/embed-cors.middleware.ts new file mode 100644 index 0000000..ff02539 --- /dev/null +++ b/src/embed/embed-cors.middleware.ts @@ -0,0 +1,27 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class EmbedCorsMiddleware implements NestMiddleware { + private readonly logger = new Logger(EmbedCorsMiddleware.name); + + use(req: Request, res: Response, next: NextFunction) { + const origin = req.headers.origin || '*'; + this.logger.debug(`Embed CORS middleware - ${req.method} ${req.path} from ${origin}`); + + // Set CORS headers for all requests + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); + res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours + + // Handle preflight requests + if (req.method === 'OPTIONS') { + this.logger.debug('Handling OPTIONS preflight request'); + res.status(204).end(); + return; + } + + next(); + } +} diff --git a/src/embed/embed.module.ts b/src/embed/embed.module.ts new file mode 100644 index 0000000..010405f --- /dev/null +++ b/src/embed/embed.module.ts @@ -0,0 +1,18 @@ +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; +import { LeadEmbedController } from './lead-embed.controller'; +import { EmbedCorsMiddleware } from './embed-cors.middleware'; + +@Module({ + imports: [PlatformModule], + controllers: [LeadEmbedController], +}) +export class EmbedModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(EmbedCorsMiddleware) + .forRoutes( + { path: 'embed/*', method: RequestMethod.ALL } + ); + } +} diff --git a/src/embed/lead-embed.controller.ts b/src/embed/lead-embed.controller.ts new file mode 100644 index 0000000..86b2b5d --- /dev/null +++ b/src/embed/lead-embed.controller.ts @@ -0,0 +1,176 @@ +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('platform.apiKey') ?? ''; + } + + @Post('create') + async handleLeadCreation(@Body() body: Record) { + 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( + `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): Record { + const leadData: Record = {}; + + 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 | null { + const type = body.type || body.interested_service || body.interestedService; + + if (!type) { + return body.department || null; + } + + const serviceMap: Record = { + '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, + authHeader: string, + ): Promise { + try { + const activityType = body.type === 'consultation' || body.type === 'appointment' + ? 'APPOINTMENT_BOOKED' + : 'FORM_SUBMITTED'; + + 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( + `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}`); + } + } +} diff --git a/src/main.ts b/src/main.ts index eb13e25..4d42889 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ async function bootstrap() { credentials: true, }); + const port = config.get('port'); await app.listen(port); console.log(`Helix Engage Server running on port ${port}`); diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index cc8389a..c66ff57 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types'; +import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, CreateLeadInput, UpdateLeadInput } from './platform.types'; @Injectable() export class PlatformGraphqlService { @@ -120,6 +120,16 @@ export class PlatformGraphqlService { return data.createLeadActivity; } + async createLead(input: CreateLeadInput): Promise<{ id: string }> { + const data = await this.query<{ createLead: { id: string } }>( + `mutation CreateLead($data: LeadCreateInput!) { + createLead(data: $data) { id } + }`, + { data: input }, + ); + return data.createLead; + } + // --- Token passthrough versions (for user-driven requests) --- async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise { diff --git a/src/platform/platform.types.ts b/src/platform/platform.types.ts index 490d5f3..15ad70c 100644 --- a/src/platform/platform.types.ts +++ b/src/platform/platform.types.ts @@ -62,6 +62,19 @@ export type CreateLeadActivityInput = { leadId: string; }; +export type CreateLeadInput = { + name: string; + contactName?: { firstName: string; lastName?: string }; + contactPhone?: { primaryPhoneNumber: string }; + contactEmail?: { primaryEmailAddress: string }; + source?: string; + status?: string; + interestedService?: string; + assignedAgent?: string; + campaignId?: string; + notes?: string; +}; + export type UpdateLeadInput = { leadStatus?: string; lastContactedAt?: string; From 09c7930b52fa9fabae1db343d587ff46516d7c8d Mon Sep 17 00:00:00 2001 From: moulichand16 Date: Fri, 27 Mar 2026 16:05:18 +0530 Subject: [PATCH 8/8] fixed cors --- .env.example | 2 +- src/config/configuration.ts | 5 ++++- src/embed/embed-cors.middleware.ts | 27 --------------------------- src/embed/embed.module.ts | 13 ++----------- src/embed/lead-embed.controller.ts | 3 ++- src/main.ts | 7 +++++-- 6 files changed, 14 insertions(+), 43 deletions(-) delete mode 100644 src/embed/embed-cors.middleware.ts diff --git a/.env.example b/.env.example index fb41b17..6fed60c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Server PORT=4100 -CORS_ORIGIN=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://localhost:8000 # Fortytwo Platform PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 48ad069..c4b4356 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,6 +1,9 @@ export default () => ({ port: parseInt(process.env.PORT ?? '4100', 10), - corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173', + corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173') + .split(',') + .map(origin => origin.trim()) + .filter(origin => origin.length > 0), platform: { graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql', apiKey: process.env.PLATFORM_API_KEY ?? '', diff --git a/src/embed/embed-cors.middleware.ts b/src/embed/embed-cors.middleware.ts deleted file mode 100644 index ff02539..0000000 --- a/src/embed/embed-cors.middleware.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; - -@Injectable() -export class EmbedCorsMiddleware implements NestMiddleware { - private readonly logger = new Logger(EmbedCorsMiddleware.name); - - use(req: Request, res: Response, next: NextFunction) { - const origin = req.headers.origin || '*'; - this.logger.debug(`Embed CORS middleware - ${req.method} ${req.path} from ${origin}`); - - // Set CORS headers for all requests - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); - res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours - - // Handle preflight requests - if (req.method === 'OPTIONS') { - this.logger.debug('Handling OPTIONS preflight request'); - res.status(204).end(); - return; - } - - next(); - } -} diff --git a/src/embed/embed.module.ts b/src/embed/embed.module.ts index 010405f..995338a 100644 --- a/src/embed/embed.module.ts +++ b/src/embed/embed.module.ts @@ -1,18 +1,9 @@ -import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { LeadEmbedController } from './lead-embed.controller'; -import { EmbedCorsMiddleware } from './embed-cors.middleware'; @Module({ imports: [PlatformModule], controllers: [LeadEmbedController], }) -export class EmbedModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer - .apply(EmbedCorsMiddleware) - .forRoutes( - { path: 'embed/*', method: RequestMethod.ALL } - ); - } -} +export class EmbedModule {} diff --git a/src/embed/lead-embed.controller.ts b/src/embed/lead-embed.controller.ts index 86b2b5d..ca6ac6c 100644 --- a/src/embed/lead-embed.controller.ts +++ b/src/embed/lead-embed.controller.ts @@ -16,6 +16,7 @@ export class LeadEmbedController { @Post('create') async handleLeadCreation(@Body() body: Record) { + 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}` : ''; @@ -138,7 +139,7 @@ export class LeadEmbedController { try { const activityType = body.type === 'consultation' || body.type === 'appointment' ? 'APPOINTMENT_BOOKED' - : 'FORM_SUBMITTED'; + : 'CALL_RECEIVED'; let summary = 'Lead submitted via web form'; if (body.type) { diff --git a/src/main.ts b/src/main.ts index 4d42889..6ea8785 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,12 +6,15 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); + const corsOrigins = config.get('corsOrigins') || ['http://localhost:5173']; + app.enableCors({ - origin: config.get('corsOrigin'), + origin: corsOrigins, credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Accept', 'Authorization'], }); - const port = config.get('port'); await app.listen(port); console.log(`Helix Engage Server running on port ${port}`);