mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Three changes: 1. Await Ozonetel logout in /auth/logout — prevents race condition when agent re-logs in quickly via "Remember me". The fire-and-forget logoutAgent() left a window where the next loginAgent() arrived while Ozonetel was still processing the previous logout, leaving the agent stuck in "Telephony Unavailable". (#559) 2. Use agentConfig.sipPassword (from Agent entity) instead of OZONETEL_AGENT_PASSWORD env var for login/logout/force-ready. The env var was a single shared credential that ignored per-agent passwords. Removed hardcoded "Test123$" fallback. Force-ready now looks up the Agent entity by ozonetelAgentId to get the correct sipPassword + sipExtension. 3. Missed-calls worklist query now fetches campaign { id campaignName } so the frontend Branch column can show the campaign name instead of the raw DID phone number. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
294 lines
13 KiB
TypeScript
294 lines
13 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';
|
|
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<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') || 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}`);
|
|
});
|
|
|
|
this.ozonetelAgent.loginAgent({
|
|
agentId: agentConfig.ozonetelAgentId,
|
|
password: agentConfig.sipPassword,
|
|
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}`);
|
|
|
|
// Await the Ozonetel logout so it completes before the
|
|
// HTTP response returns. Without this, a fast re-login
|
|
// (e.g. "remember me" auto-fill) races the logout and
|
|
// the agent lands in "Telephony Unavailable" because
|
|
// Ozonetel receives login while still processing logout.
|
|
await this.ozonetelAgent.logoutAgent({
|
|
agentId: agentConfig.ozonetelAgentId,
|
|
password: agentConfig.sipPassword,
|
|
}).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' };
|
|
}
|
|
}
|
|
}
|