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'; import { TelephonyConfigService } from '../config/telephony-config.service'; @Controller('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); private readonly graphqlUrl: string; private readonly workspaceSubdomain: string; private readonly origin: string; constructor( private config: ConfigService, private ozonetelAgent: OzonetelAgentService, private sessionService: SessionService, private agentConfigService: AgentConfigService, private telephony: TelephonyConfigService, ) { this.graphqlUrl = config.get('platform.graphqlUrl')!; this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev'; this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010'; } @Post('login') async login(@Body() body: { email: string; password: string }, @Req() req: Request) { this.logger.log(`Login attempt for ${body.email}`); try { // Step 1: Get login token const loginRes = await axios.post(this.graphqlUrl, { query: `mutation GetLoginToken($email: String!, $password: String!) { getLoginTokenFromCredentials( email: $email password: $password origin: "${this.origin}" ) { loginToken { token } } }`, variables: { email: body.email, password: body.password }, }, { headers: { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': this.workspaceSubdomain, }, }); if (loginRes.data.errors) { throw new HttpException( loginRes.data.errors[0]?.message ?? 'Login failed', 401, ); } const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token; // Step 2: Exchange for access + refresh tokens const tokenRes = await axios.post(this.graphqlUrl, { query: `mutation GetAuthTokens($loginToken: String!) { getAuthTokensFromLoginToken( loginToken: $loginToken origin: "${this.origin}" ) { tokens { accessOrWorkspaceAgnosticToken { token } refreshToken { token } } } }`, variables: { loginToken }, }, { headers: { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': this.workspaceSubdomain, }, }); if (tokenRes.data.errors) { throw new HttpException( tokenRes.data.errors[0]?.message ?? 'Token exchange failed', 401, ); } const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens; const accessToken = tokens.accessOrWorkspaceAgnosticToken.token; // Step 3: Fetch user profile with roles const profileRes = await axios.post(this.graphqlUrl, { query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`, }, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, }); const currentUser = profileRes.data?.data?.currentUser; const workspaceMember = currentUser?.workspaceMember; const roles = workspaceMember?.roles ?? []; const roleLabels = roles.map((r: any) => r.label); // Determine app role from platform roles let appRole = 'executive'; // default if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) { appRole = 'admin'; } else if (roleLabels.includes('HelixEngage User')) { const email = workspaceMember?.userEmail ?? body.email; appRole = email.includes('cc') ? 'cc-agent' : 'executive'; } this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`); // Check if user has an Agent entity with SIP config — applies to ALL roles let agentConfigResponse: any = undefined; const memberId = workspaceMember?.id; if (memberId) { const agentConfig = await this.agentConfigService.getByMemberId(memberId); if (agentConfig) { // Agent entity found — set up SIP + Ozonetel 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); } await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp); this.ozonetelAgent.refreshToken().catch(err => { this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`); }); const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$'; this.ozonetelAgent.loginAgent({ agentId: agentConfig.ozonetelAgentId, password: ozAgentPassword, 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(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`); } else if (appRole === 'cc-agent') { // CC agent role but no Agent entity — block login throw new HttpException('Agent account not configured. Contact administrator.', 403); } else { this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`); } } // Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT) const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim(); if (agentFullName) { await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400); } return { accessToken, refreshToken: tokens.refreshToken.token, user: { id: currentUser?.id, email: currentUser?.email, firstName: workspaceMember?.name?.firstName ?? '', lastName: workspaceMember?.name?.lastName ?? '', avatarUrl: workspaceMember?.avatarUrl, role: appRole, platformRoles: roleLabels, }, ...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}), }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error(`Login proxy failed: ${error}`); throw new HttpException('Authentication service unavailable', 503); } } @Post('refresh') async refresh(@Body() body: { refreshToken: string }) { if (!body.refreshToken) { throw new HttpException('refreshToken required', 400); } this.logger.log('Token refresh request'); try { const res = await axios.post(this.graphqlUrl, { query: `mutation RefreshToken($token: String!) { renewToken(appToken: $token) { tokens { accessOrWorkspaceAgnosticToken { token expiresAt } refreshToken { token } } } }`, variables: { token: body.refreshToken }, }, { headers: { 'Content-Type': 'application/json' }, }); if (res.data.errors) { this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`); throw new HttpException('Token refresh failed', 401); } const tokens = res.data.data.renewToken.tokens; return { accessToken: tokens.accessOrWorkspaceAgnosticToken.token, refreshToken: tokens.refreshToken.token, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error(`Token refresh failed: ${error}`); 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: this.telephony.getConfig().ozonetel.agentPassword || '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' }; } } }