diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fed60c --- /dev/null +++ b/.env.example @@ -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= diff --git a/package-lock.json b/package-lock.json index 2456c0d..f8649f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,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" @@ -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": { "version": "8.0.2", "resolved": "http://localhost:4873/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5284,6 +5291,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", @@ -5605,6 +5621,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", @@ -7165,6 +7190,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", @@ -8507,6 +8556,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", @@ -9718,6 +9779,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", @@ -10258,6 +10340,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 4bf442c..bef6ba1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,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/app.module.ts b/src/app.module.ts index da985ef..a86a90b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,8 @@ 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'; +import { EmbedModule } from './embed/embed.module'; @Module({ imports: [ @@ -30,6 +32,8 @@ import { SearchModule } from './search/search.module'; WorklistModule, CallAssistModule, SearchModule, + SupervisorModule, + EmbedModule, ], }) export class AppModule {} diff --git a/src/auth/agent-config.service.ts b/src/auth/agent-config.service.ts new file mode 100644 index 0000000..af34194 --- /dev/null +++ b/src/auth/agent-config.service.ts @@ -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(); + 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 559d932..374e22b 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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 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 +24,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 = @@ -22,7 +35,10 @@ 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,18 +144,63 @@ export class AuthController { `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') { - 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 = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; - const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814'; - this.ozonetelAgent .loginAgent({ - agentId: ozAgentId, + agentId: agentConfig.ozonetelAgentId, password: ozAgentPassword, - phoneNumber: ozSipId, + phoneNumber: agentConfig.sipExtension, mode: 'blended', }) .catch((err) => { @@ -147,6 +208,19 @@ export class AuthController { `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 { @@ -161,6 +235,7 @@ export class AuthController { role: appRole, platformRoles: roleLabels, }, + ...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}), }; } catch (error) { if (error instanceof HttpException) throw error; @@ -214,4 +289,76 @@ 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 b26eeab..17cf777 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..9bef931 --- /dev/null +++ b/src/auth/session.service.ts @@ -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('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 { + 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 { + const session = await this.getSession(agentId); + return session ? session.memberId : null; + } + + 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 5ed1011..3c71656 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', @@ -13,6 +16,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', diff --git a/src/embed/embed.module.ts b/src/embed/embed.module.ts new file mode 100644 index 0000000..807752f --- /dev/null +++ b/src/embed/embed.module.ts @@ -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 {} diff --git a/src/embed/lead-embed.controller.ts b/src/embed/lead-embed.controller.ts new file mode 100644 index 0000000..4bad29d --- /dev/null +++ b/src/embed/lead-embed.controller.ts @@ -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('platform.apiKey') ?? ''; + } + + @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}` : ''; + 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' + : '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( + `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 d68a187..e26e612 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,9 +7,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 swaggerConfig = new DocumentBuilder() diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 866cf84..7abc5ca 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -23,6 +23,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'); @@ -37,7 +41,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; } @@ -45,6 +49,11 @@ 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; @@ -81,20 +90,48 @@ 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`, + `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)}`); return data; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Agent login failed: ${error.message}`); throw error; } @@ -129,10 +166,10 @@ export class OzonetelAgentService { }, }, ); - 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) : ''; @@ -362,6 +399,7 @@ export class OzonetelAgentService { } return []; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Abandon calls failed: ${error.message}`); return []; } @@ -458,6 +496,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; } @@ -487,6 +526,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'; } @@ -526,6 +566,7 @@ export class OzonetelAgentService { ); return response.data; } catch (error: any) { + if (error?.response?.status === 401) this.invalidateToken(); this.logger.error(`Agent logout failed: ${error.message}`); throw error; } diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index ea3bfe2..3b6ae3e 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -6,6 +6,7 @@ import type { LeadActivityNode, CreateCallInput, CreateLeadActivityInput, + CreateLeadInput, UpdateLeadInput, } from './platform.types'; @@ -138,6 +139,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( diff --git a/src/platform/platform.types.ts b/src/platform/platform.types.ts index 2558850..6f585f5 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; diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts new file mode 100644 index 0000000..ff1a959 --- /dev/null +++ b/src/supervisor/supervisor.controller.ts @@ -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 }; + } +} diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts new file mode 100644 index 0000000..bdb2134 --- /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..53b91f0 --- /dev/null +++ b/src/supervisor/supervisor.service.ts @@ -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(); + + 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 }; + } +}