4 Commits

Author SHA1 Message Date
0f5bd7d61a ci: fix Teams notification — use Adaptive Card with curl
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 15:37:20 +05:30
f1313f0e2f ci: use Teams notification plugin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:34:30 +05:30
44f1ec36e1 ci: add Woodpecker pipeline — unit tests + Teams notification
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:29:49 +05:30
4bd08a9b02 fix: remove defaultAgentId fallback — require agentId from caller
agent-state, dispose, dial, performance, force-ready, unlock-agent
all required agentId from the request body now. No silent fallback
to OZONETEL_AGENT_ID env var which caused cross-tenant operations
in multi-agent setups (Ramaiah operations hitting Global's agent).

OZONETEL_AGENT_ID removed from telephony env seed list. Hardcoded
fallbacks (agent3, Test123$, 521814) deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:10:31 +05:30
4 changed files with 52 additions and 28 deletions

24
.woodpecker.yml Normal file
View File

@@ -0,0 +1,24 @@
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
when:
- event: [push, manual]
steps:
unit-tests:
image: node:20
commands:
- npm ci
- npm test -- --ci --forceExit
notify-teams:
image: curlimages/curl
environment:
TEAMS_WEBHOOK:
from_secret: teams_webhook
commands:
- >
curl -s -X POST "$TEAMS_WEBHOOK"
-H "Content-Type:application/json"
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage Server — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/2/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
when:
- status: [success, failure]

View File

@@ -62,7 +62,8 @@ export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
// Field-by-field mapping from legacy env var names to config paths. Used by // Field-by-field mapping from legacy env var names to config paths. Used by
// the first-boot seeder. Keep in sync with the migration target sites. // the first-boot seeder. Keep in sync with the migration target sites.
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [ export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
{ env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] }, // OZONETEL_AGENT_ID removed — agentId is per-user on the Agent entity,
// not a sidecar-level config. All endpoints require agentId from caller.
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] }, { env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] }, { env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] }, { env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },

View File

@@ -1,4 +1,4 @@
import { Controller, Post, UseGuards, Logger } from '@nestjs/common'; import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
import { MaintGuard } from './maint.guard'; import { MaintGuard } from './maint.guard';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@@ -22,11 +22,14 @@ export class MaintController {
) {} ) {}
@Post('force-ready') @Post('force-ready')
async forceReady() { async forceReady(@Body() body: { agentId: string }) {
if (!body?.agentId) throw new HttpException('agentId required', 400);
const agentId = body.agentId;
const oz = this.telephony.getConfig().ozonetel; const oz = this.telephony.getConfig().ozonetel;
const agentId = oz.agentId || 'agent3'; const password = oz.agentPassword;
const password = oz.agentPassword || 'Test123$'; if (!password) throw new HttpException('agent password not configured', 400);
const sipId = oz.sipId || '521814'; const sipId = oz.sipId;
if (!sipId) throw new HttpException('SIP ID not configured', 400);
this.logger.log(`[MAINT] Force ready: agent=${agentId}`); this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
@@ -48,8 +51,9 @@ export class MaintController {
} }
@Post('unlock-agent') @Post('unlock-agent')
async unlockAgent() { async unlockAgent(@Body() body: { agentId: string }) {
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3'; if (!body?.agentId) throw new HttpException('agentId required', 400);
const agentId = body.agentId;
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`); this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
try { try {

View File

@@ -20,15 +20,9 @@ export class OzonetelAgentController {
private readonly supervisor: SupervisorService, private readonly supervisor: SupervisorService,
) {} ) {}
// Read-through accessors so admin updates take effect immediately. private requireAgentId(agentId: string | undefined | null): string {
private get defaultAgentId(): string { if (!agentId) throw new HttpException('agentId required', 400);
return this.telephony.getConfig().ozonetel.agentId || 'agent3'; return agentId;
}
private get defaultAgentPassword(): string {
return this.telephony.getConfig().ozonetel.agentPassword;
}
private get defaultSipId(): string {
return this.telephony.getConfig().ozonetel.sipId || '521814';
} }
@Post('agent-login') @Post('agent-login')
@@ -67,17 +61,18 @@ export class OzonetelAgentController {
@Post('agent-state') @Post('agent-state')
async agentState( async agentState(
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string }, @Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
) { ) {
if (!body.state) { if (!body.state) {
throw new HttpException('state required', 400); throw new HttpException('state required', 400);
} }
const agentId = this.requireAgentId(body.agentId);
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId}${body.state} (${body.pauseReason ?? 'none'})`); this.logger.log(`[AGENT-STATE] ${agentId}${body.state} (${body.pauseReason ?? 'none'})`);
try { try {
const result = await this.ozonetelAgent.changeAgentState({ const result = await this.ozonetelAgent.changeAgentState({
agentId: this.defaultAgentId, agentId,
state: body.state, state: body.state,
pauseReason: body.pauseReason, pauseReason: body.pauseReason,
}); });
@@ -86,7 +81,7 @@ export class OzonetelAgentController {
// Auto-assign missed call when agent goes Ready // Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') { if (body.state === 'Ready') {
try { try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId); const assigned = await this.missedQueue.assignNext(agentId);
if (assigned) { if (assigned) {
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`); this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
return { ...result, assignedCall: assigned }; return { ...result, assignedCall: assigned };
@@ -112,7 +107,7 @@ export class OzonetelAgentController {
@Body() body: { @Body() body: {
ucid: string; ucid: string;
disposition: string; disposition: string;
agentId?: string; agentId: string;
callerPhone?: string; callerPhone?: string;
direction?: string; direction?: string;
durationSec?: number; durationSec?: number;
@@ -125,7 +120,7 @@ export class OzonetelAgentController {
throw new HttpException('ucid and disposition required', 400); throw new HttpException('ucid and disposition required', 400);
} }
const agentId = body.agentId ?? this.defaultAgentId; const agentId = this.requireAgentId(body.agentId);
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition); const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
// Cancel the ACW auto-dispose timer — the frontend submitted disposition // Cancel the ACW auto-dispose timer — the frontend submitted disposition
@@ -200,7 +195,7 @@ export class OzonetelAgentController {
// Auto-assign next missed call to this agent // Auto-assign next missed call to this agent
try { try {
await this.missedQueue.assignNext(this.defaultAgentId); await this.missedQueue.assignNext(agentId);
} catch (err) { } catch (err) {
this.logger.warn(`Auto-assignment after dispose failed: ${err}`); this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
} }
@@ -209,7 +204,7 @@ export class OzonetelAgentController {
this.eventBus.emit(Topics.CALL_COMPLETED, { this.eventBus.emit(Topics.CALL_COMPLETED, {
callId: null, callId: null,
ucid: body.ucid, ucid: body.ucid,
agentId: this.defaultAgentId, agentId,
callerPhone: body.callerPhone ?? '', callerPhone: body.callerPhone ?? '',
direction: body.direction ?? 'INBOUND', direction: body.direction ?? 'INBOUND',
durationSec: body.durationSec ?? 0, durationSec: body.durationSec ?? 0,
@@ -224,13 +219,13 @@ export class OzonetelAgentController {
@Post('dial') @Post('dial')
async dial( async dial(
@Body() body: { phoneNumber: string; agentId?: string; campaignName?: string; leadId?: string }, @Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
) { ) {
if (!body.phoneNumber) { if (!body.phoneNumber) {
throw new HttpException('phoneNumber required', 400); throw new HttpException('phoneNumber required', 400);
} }
const agentId = body.agentId ?? this.defaultAgentId; const agentId = this.requireAgentId(body.agentId);
const did = this.telephony.getConfig().ozonetel.did; const did = this.telephony.getConfig().ozonetel.did;
const campaignName = body.campaignName const campaignName = body.campaignName
|| this.telephony.getConfig().ozonetel.campaignName || this.telephony.getConfig().ozonetel.campaignName
@@ -323,7 +318,7 @@ export class OzonetelAgentController {
@Get('performance') @Get('performance')
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) { async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
const agent = agentId ?? this.defaultAgentId; const agent = this.requireAgentId(agentId);
const targetDate = date ?? new Date().toISOString().split('T')[0]; const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(`Performance: date=${targetDate} agent=${agent}`); this.logger.log(`Performance: date=${targetDate} agent=${agent}`);