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 { 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, ) { 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')) { appRole = 'admin'; } else if (roleLabels.includes('HelixEngage User')) { // Distinguish CC agent from executive by email convention or config // For now, emails containing 'cc' map to cc-agent 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(', ')})`, ); // 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 — 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$'; 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( `CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`, ); } 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: 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' }; } } }