Files
helix-engage-server/src/auth/auth.controller.ts
2026-03-27 16:57:22 +05:30

365 lines
11 KiB
TypeScript

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<string>('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' };
}
}
}