mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
88
package-lock.json
generated
88
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
70
src/auth/agent-config.service.ts
Normal file
70
src/auth/agent-config.service.ts
Normal file
@@ -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<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,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<string>('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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
40
src/auth/session.service.ts
Normal file
40
src/auth/session.service.ts
Normal file
@@ -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<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): Promise<void> {
|
||||
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
|
||||
}
|
||||
|
||||
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||
return this.redis.get(this.key(agentId));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user