From a42d479f06c482103ad1bd1f8fde0d133eb2d515 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 18 Mar 2026 07:15:47 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20wire=20sidecar=20to=20platform=20?= =?UTF-8?q?=E2=80=94=20auth=20proxy=20with=20workspace=20subdomain,=20Grap?= =?UTF-8?q?hQL=20proxy,=20health=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 6 ++ src/auth/auth.controller.ts | 56 ++++++----- src/graphql-proxy/graphql-proxy.controller.ts | 45 +++++++++ src/graphql-proxy/graphql-proxy.module.ts | 7 ++ src/health/health.controller.ts | 38 ++++++++ src/health/health.module.ts | 7 ++ src/ozonetel/ozonetel-agent.controller.ts | 43 +++++++++ src/ozonetel/ozonetel-agent.module.ts | 10 ++ src/ozonetel/ozonetel-agent.service.ts | 94 +++++++++++++++++++ 9 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 src/graphql-proxy/graphql-proxy.controller.ts create mode 100644 src/graphql-proxy/graphql-proxy.module.ts create mode 100644 src/health/health.controller.ts create mode 100644 src/health/health.module.ts create mode 100644 src/ozonetel/ozonetel-agent.controller.ts create mode 100644 src/ozonetel/ozonetel-agent.module.ts create mode 100644 src/ozonetel/ozonetel-agent.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index d271021..97480ef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,9 @@ import { AuthModule } from './auth/auth.module'; import { PlatformModule } from './platform/platform.module'; import { ExotelModule } from './exotel/exotel.module'; import { CallEventsModule } from './call-events/call-events.module'; +import { OzonetelAgentModule } from './ozonetel/ozonetel-agent.module'; +import { GraphqlProxyModule } from './graphql-proxy/graphql-proxy.module'; +import { HealthModule } from './health/health.module'; @Module({ imports: [ @@ -18,6 +21,9 @@ import { CallEventsModule } from './call-events/call-events.module'; PlatformModule, ExotelModule, CallEventsModule, + OzonetelAgentModule, + GraphqlProxyModule, + HealthModule, ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 4c74e16..9861efd 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -6,9 +6,13 @@ import axios from 'axios'; 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) { 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') @@ -16,46 +20,40 @@ export class AuthController { this.logger.log(`Login attempt for ${body.email}`); try { - const response = await axios.post(this.graphqlUrl, { + // 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: "http://localhost:5173" + origin: "${this.origin}" ) { loginToken { token } } }`, variables: { email: body.email, password: body.password }, }, { - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Workspace-Subdomain': this.workspaceSubdomain, + }, }); - if (response.data.errors) { + if (loginRes.data.errors) { throw new HttpException( - response.data.errors[0]?.message ?? 'Login failed', + loginRes.data.errors[0]?.message ?? 'Login failed', 401, ); } - return response.data.data.getLoginTokenFromCredentials; - } catch (error) { - if (error instanceof HttpException) throw error; - this.logger.error(`Login proxy failed: ${error}`); - throw new HttpException('Authentication service unavailable', 503); - } - } + const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token; - @Post('tokens') - async getTokens(@Body() body: { loginToken: string }) { - this.logger.log('Exchanging login token for access token'); - - try { - const response = await axios.post(this.graphqlUrl, { + // Step 2: Exchange for access + refresh tokens + const tokenRes = await axios.post(this.graphqlUrl, { query: `mutation GetAuthTokens($loginToken: String!) { getAuthTokensFromLoginToken( loginToken: $loginToken - origin: "http://localhost:5173" + origin: "${this.origin}" ) { tokens { accessOrWorkspaceAgnosticToken { token } @@ -63,22 +61,30 @@ export class AuthController { } } }`, - variables: { loginToken: body.loginToken }, + variables: { loginToken }, }, { - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Workspace-Subdomain': this.workspaceSubdomain, + }, }); - if (response.data.errors) { + if (tokenRes.data.errors) { throw new HttpException( - response.data.errors[0]?.message ?? 'Token exchange failed', + tokenRes.data.errors[0]?.message ?? 'Token exchange failed', 401, ); } - return response.data.data.getAuthTokensFromLoginToken; + const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens; + + return { + accessToken: tokens.accessOrWorkspaceAgnosticToken.token, + refreshToken: tokens.refreshToken.token, + }; } catch (error) { if (error instanceof HttpException) throw error; - this.logger.error(`Token exchange proxy failed: ${error}`); + this.logger.error(`Login proxy failed: ${error}`); throw new HttpException('Authentication service unavailable', 503); } } diff --git a/src/graphql-proxy/graphql-proxy.controller.ts b/src/graphql-proxy/graphql-proxy.controller.ts new file mode 100644 index 0000000..a7e3d58 --- /dev/null +++ b/src/graphql-proxy/graphql-proxy.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Post, Req, Res, Logger, HttpException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import axios from 'axios'; + +@Controller('graphql') +export class GraphqlProxyController { + private readonly logger = new Logger(GraphqlProxyController.name); + private readonly graphqlUrl: string; + + constructor(private config: ConfigService) { + this.graphqlUrl = config.get('platform.graphqlUrl')!; + } + + @Post() + async proxy(@Req() req: Request, @Res() res: Response) { + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new HttpException('Authorization header required', 401); + } + + try { + const response = await axios.post( + this.graphqlUrl, + req.body, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': authHeader, + }, + }, + ); + + res.status(response.status).json(response.data); + } catch (error: any) { + if (error.response) { + res.status(error.response.status).json(error.response.data); + } else { + this.logger.error(`GraphQL proxy error: ${error.message}`); + throw new HttpException('Platform unreachable', 503); + } + } + } +} diff --git a/src/graphql-proxy/graphql-proxy.module.ts b/src/graphql-proxy/graphql-proxy.module.ts new file mode 100644 index 0000000..a830ebe --- /dev/null +++ b/src/graphql-proxy/graphql-proxy.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { GraphqlProxyController } from './graphql-proxy.controller'; + +@Module({ + controllers: [GraphqlProxyController], +}) +export class GraphqlProxyModule {} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..444a71a --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +@Controller('api/health') +export class HealthController { + private readonly logger = new Logger(HealthController.name); + private readonly graphqlUrl: string; + + constructor(private config: ConfigService) { + this.graphqlUrl = config.get('platform.graphqlUrl')!; + } + + @Get() + async check() { + let platformReachable = false; + let platformLatency = 0; + + try { + const start = Date.now(); + await axios.post(this.graphqlUrl, { query: '{ __typename }' }, { + headers: { 'Content-Type': 'application/json' }, + timeout: 5000, + }); + platformLatency = Date.now() - start; + platformReachable = true; + } catch { + platformReachable = false; + } + + return { + status: platformReachable ? 'ok' : 'degraded', + platform: { reachable: platformReachable, latencyMs: platformLatency }, + ozonetel: { configured: !!process.env.EXOTEL_API_KEY }, + ai: { configured: !!process.env.ANTHROPIC_API_KEY }, + }; + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..d42135a --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts new file mode 100644 index 0000000..c5a0ff5 --- /dev/null +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common'; +import { OzonetelAgentService } from './ozonetel-agent.service'; + +@Controller('api/ozonetel') +export class OzonetelAgentController { + private readonly logger = new Logger(OzonetelAgentController.name); + + constructor(private readonly ozonetelAgent: OzonetelAgentService) {} + + @Post('agent-login') + async agentLogin( + @Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string }, + ) { + this.logger.log(`Agent login request for ${body.agentId}`); + + try { + const result = await this.ozonetelAgent.loginAgent(body); + return result; + } catch (error: any) { + throw new HttpException( + error.response?.data?.message ?? 'Agent login failed', + error.response?.status ?? 500, + ); + } + } + + @Post('agent-logout') + async agentLogout( + @Body() body: { agentId: string; password: string }, + ) { + this.logger.log(`Agent logout request for ${body.agentId}`); + + try { + const result = await this.ozonetelAgent.logoutAgent(body); + return result; + } catch (error: any) { + throw new HttpException( + error.response?.data?.message ?? 'Agent logout failed', + error.response?.status ?? 500, + ); + } + } +} diff --git a/src/ozonetel/ozonetel-agent.module.ts b/src/ozonetel/ozonetel-agent.module.ts new file mode 100644 index 0000000..e592c0b --- /dev/null +++ b/src/ozonetel/ozonetel-agent.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OzonetelAgentController } from './ozonetel-agent.controller'; +import { OzonetelAgentService } from './ozonetel-agent.service'; + +@Module({ + controllers: [OzonetelAgentController], + providers: [OzonetelAgentService], + exports: [OzonetelAgentService], +}) +export class OzonetelAgentModule {} diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts new file mode 100644 index 0000000..976dac1 --- /dev/null +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +@Injectable() +export class OzonetelAgentService { + private readonly logger = new Logger(OzonetelAgentService.name); + private readonly apiDomain: string; + private readonly apiKey: string; + private readonly accountId: string; + + constructor(private config: ConfigService) { + this.apiDomain = config.get('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com'; + this.apiKey = config.get('exotel.apiKey') ?? ''; + this.accountId = config.get('exotel.accountSid') ?? ''; + } + + async loginAgent(params: { + agentId: string; + password: string; + phoneNumber: string; + mode?: string; + }): Promise<{ status: string; message: string }> { + const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`; + + this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`); + + try { + const response = await axios.post( + url, + new URLSearchParams({ + userName: this.accountId, + apiKey: this.apiKey, + phoneNumber: params.phoneNumber, + action: 'login', + mode: params.mode ?? 'blended', + state: 'Ready', + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth: { + username: params.agentId, + password: params.password, + }, + }, + ); + + this.logger.log(`Agent login response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + this.logger.error(`Agent login failed: ${error.message}`); + throw error; + } + } + + async logoutAgent(params: { + agentId: string; + password: string; + }): Promise<{ status: string; message: string }> { + const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`; + + this.logger.log(`Logging out agent ${params.agentId}`); + + try { + const response = await axios.post( + url, + new URLSearchParams({ + userName: this.accountId, + apiKey: this.apiKey, + action: 'logout', + mode: 'blended', + state: 'Ready', + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth: { + username: params.agentId, + password: params.password, + }, + }, + ); + + this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + this.logger.error(`Agent logout failed: ${error.message}`); + throw error; + } + } +}