From ca3f2e9b63fc2c1880156752e06e3439d23aed89 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 00:20:05 +0530 Subject: [PATCH 1/8] fix(ui): persist CZentrix panel on innerpage routes, move dev sim button to bottom left Co-Authored-By: Claude Fable 5 --- .../components/dashboard-footer.component.ts | 62 +-------- .../dashboard/dashboard.component.ts | 12 +- src/app/app.html | 1 + src/app/app.ts | 4 +- .../components/layout/cti-panel.component.ts | 118 ++++++++++++++++++ 5 files changed, 128 insertions(+), 69 deletions(-) create mode 100644 src/app/shared/components/layout/cti-panel.component.ts diff --git a/src/app/app-modules/dashboard/components/dashboard-footer.component.ts b/src/app/app-modules/dashboard/components/dashboard-footer.component.ts index f996e93..bf907e7 100644 --- a/src/app/app-modules/dashboard/components/dashboard-footer.component.ts +++ b/src/app/app-modules/dashboard/components/dashboard-footer.component.ts @@ -20,44 +20,30 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, - signal, -} from '@angular/core'; -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMail } from '@ng-icons/lucide'; -import { ZardButtonComponent } from '@common-ui/ui/button'; - import { AppFooterComponent } from '@/shared/components/layout/app-footer.component'; import { AuthStore } from '../../core/auth/auth.store'; import { I18nService } from '../../core/i18n/i18n.service'; import { TranslatePipe } from '../../core/i18n/translate.pipe'; -import { ConfigService } from '../../core/services/config.service'; -/** Static brand constants shown in the footer (not translatable). */ -const CZENTRIX_LABEL = 'CZentrix'; -const CTI_HANDLER_PATH = 'bar/cti_handler.php'; const FEEDBACK_ROUTE = '/feedback'; /** * Dashboard footer: the shared copyright / version chrome plus the post-logout - * feedback link and — for call-handling roles — the CZentrix button that - * toggles the CTI (telephony soft-phone) bar. + * feedback link. The CZentrix CTI panel is no longer footer-owned — it lives in + * the app-root `CtiPanelComponent` so it persists across all routes. */ @Component({ selector: 'app-dashboard-footer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIcon, ZardButtonComponent, TranslatePipe, AppFooterComponent], + imports: [NgIcon, TranslatePipe, AppFooterComponent], viewProviders: [provideIcons({ lucideMail })], template: ` @@ -69,56 +55,16 @@ const FEEDBACK_ROUTE = '/feedback'; - - @if (showCzentrix() && ctiOpen() && ctiUrl(); as src) { - - } `, }) export class DashboardFooterComponent { private readonly i18n = inject(I18nService); - private readonly config = inject(ConfigService); private readonly router = inject(Router); - private readonly sanitizer = inject(DomSanitizer); private readonly authStore = inject(AuthStore); readonly lang = this.i18n.language; - /** Whether to show the CZentrix CTI toggle (call-handling roles only). */ - readonly showCzentrix = input(false); - /** Telephony agent id used to address the CTI handler. */ - readonly agentId = input(null); - - readonly czentrixLabel = CZENTRIX_LABEL; - - private readonly _ctiOpen = signal(false); - readonly ctiOpen = this._ctiOpen.asReadonly(); - - /** Sanitized CTI bar URL, or null when no agent id is available. */ - readonly ctiUrl = computed(() => { - const id = this.agentId(); - if (id === null) { - return null; - } - const url = `${this.config.getTelephonyServerURL()}${CTI_HANDLER_PATH}?e=${id}`; - return this.sanitizer.bypassSecurityTrustResourceUrl(url); - }); - - toggleCti(): void { - this._ctiOpen.update((open) => !open); - } - goToFeedback(): void { // The feedback page is anonymous: clear the session before navigating. this.authStore.clear(); diff --git a/src/app/app-modules/dashboard/dashboard.component.ts b/src/app/app-modules/dashboard/dashboard.component.ts index e78d053..8bab1b0 100644 --- a/src/app/app-modules/dashboard/dashboard.component.ts +++ b/src/app/app-modules/dashboard/dashboard.component.ts @@ -34,8 +34,6 @@ import { environment } from '@env/environment'; import { catchError, filter, of, switchMap, timer } from 'rxjs'; -import { ZardButtonComponent } from '@common-ui/ui/button'; - import { CallStore } from '../call/call.store'; import { parseInboundCtiMessage } from '../call/cti-message'; import { AuthStore } from '../core/auth/auth.store'; @@ -114,7 +112,6 @@ function safeOrigin(url: string): string { ReportsPanelComponent, ActivityPanelComponent, RatingPanelComponent, - ZardButtonComponent, ], template: `
@@ -154,15 +151,12 @@ function safeOrigin(url: string): string {
- + @if (!isProduction) { + + @if (ctiOpen() && ctiUrl(); as src) { + + } + } + `, +}) +export class CtiPanelComponent { + private readonly config = inject(ConfigService); + private readonly sanitizer = inject(DomSanitizer); + private readonly authStore = inject(AuthStore); + + readonly czentrixLabel = CZENTRIX_LABEL; + + private readonly _ctiOpen = signal(false); + readonly ctiOpen = this._ctiOpen.asReadonly(); + + /** + * Whether the CZentrix toggle is visible: an authenticated session with a + * selected role that is not the supervisor (who has no personal agent line). + */ + readonly showCzentrix = computed(() => { + if (!this.authStore.isAuthenticated()) { + return false; + } + const featureCode = this.authStore.currentRole()?.featureCode ?? null; + return featureCode !== null && featureCode !== SUPERVISOR_FEATURE_CODE; + }); + + /** Telephony agent id used to address the CTI handler. */ + private readonly agentId = computed(() => this.authStore.user()?.agentID ?? null); + + /** Sanitized CTI bar URL, or null when no agent id is available. */ + readonly ctiUrl = computed(() => { + const id = this.agentId(); + if (id === null) { + return null; + } + const url = `${this.config.getTelephonyServerURL()}${CTI_HANDLER_PATH}?e=${id}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + }); + + toggleCti(): void { + this._ctiOpen.update((open) => !open); + } +} From ee9a853b28916f2e78f8493e4a8a717884ed9163 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 00:36:39 +0530 Subject: [PATCH 2/8] fix(cti): move inbound postMessage handler to app scope, hide toggle when no agentID Co-Authored-By: Claude Fable 5 --- .../app-modules/call/inbound-cti.service.ts | 108 ++++++++++++++++++ .../dashboard/dashboard.component.ts | 79 +------------ src/app/app.ts | 7 +- .../components/layout/cti-panel.component.ts | 6 +- 4 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 src/app/app-modules/call/inbound-cti.service.ts diff --git a/src/app/app-modules/call/inbound-cti.service.ts b/src/app/app-modules/call/inbound-cti.service.ts new file mode 100644 index 0000000..3480aeb --- /dev/null +++ b/src/app/app-modules/call/inbound-cti.service.ts @@ -0,0 +1,108 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technologies + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { environment } from '@env/environment'; + +import { CallStore } from './call.store'; +import { parseInboundCtiMessage } from './cti-message'; + +/** + * Extract the origin from a configured base URL. Returns a token that can never + * equal a real `MessageEvent.origin` when the URL is empty/malformed, so an + * unconfigured telephony server trusts nothing rather than everything. + */ +function safeOrigin(url: string): string { + try { + return new URL(url).origin; + } catch { + return 'invalid:no-telephony-origin'; + } +} + +/** + * App-scoped listener for inbound CTI events from the CZentrix soft-phone. + * + * The CZentrix CTI iframe announces inbound calls to the host window via + * postMessage ("Accept|||INBOUND"). The iframe lives in the + * root-level CTI panel and persists across every route, so the listener must + * be app-scoped too — an inbound call must seed the {@link CallStore} and route + * into the guarded on-call workspace no matter which screen the agent is on. + * + * Instantiated once by the root `App` component; the listener stays registered + * for the lifetime of the application. + */ +@Injectable({ providedIn: 'root' }) +export class InboundCtiService { + private readonly callStore = inject(CallStore); + private readonly router = inject(Router); + + /** Origin of the CZentrix telephony server, the only trusted CTI sender. */ + private readonly telephonyOrigin = safeOrigin(environment.telephoneServer); + + constructor() { + const onMessage = (event: MessageEvent): void => { + if (!this.isTrustedCtiOrigin(event.origin)) { + return; + } + this.handleCtiMessage(event.data); + }; + window.addEventListener('message', onMessage); + inject(DestroyRef).onDestroy(() => + window.removeEventListener('message', onMessage), + ); + } + + /** + * Only accept CTI events from the CZentrix telephony origin — never from an + * arbitrary page/iframe that could forge an "inbound call". The dev simulator + * posts from this app's own origin, which is trusted in non-production builds. + */ + private isTrustedCtiOrigin(origin: string): boolean { + if (origin === this.telephonyOrigin) { + return true; + } + return !environment.production && origin === window.location.origin; + } + + /** Parse a CTI payload; on a fresh inbound call, seed state and navigate. */ + private handleCtiMessage(data: unknown): void { + const inbound = parseInboundCtiMessage(data); + if (!inbound) { + return; + } + // De-dupe: the iframe may re-post the same event for one connected call. + if ( + this.callStore.onCall() && + this.callStore.sessionId() === inbound.sessionId + ) { + return; + } + + this.callStore.startCall({ + cli: inbound.cli, + sessionId: inbound.sessionId, + }); + void this.router.navigate(['/innerpage']); + } +} diff --git a/src/app/app-modules/dashboard/dashboard.component.ts b/src/app/app-modules/dashboard/dashboard.component.ts index 8bab1b0..e0a23d9 100644 --- a/src/app/app-modules/dashboard/dashboard.component.ts +++ b/src/app/app-modules/dashboard/dashboard.component.ts @@ -23,19 +23,15 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, computed, inject, signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Router } from '@angular/router'; import { environment } from '@env/environment'; import { catchError, filter, of, switchMap, timer } from 'rxjs'; -import { CallStore } from '../call/call.store'; -import { parseInboundCtiMessage } from '../call/cti-message'; import { AuthStore } from '../core/auth/auth.store'; import { AgentState, AgentStatusService } from './agent-status.service'; import { AgentIdComponent } from './components/agent-id.component'; @@ -73,19 +69,6 @@ const AGENT_STATUS_POLL_MS = 15_000; /** States whose type suffix the legacy dashboard suppressed. */ const ON_CALL_STATES: readonly string[] = ['INCALL', 'CLOSURE']; -/** - * Extract the origin from a configured base URL. Returns a token that can never - * equal a real `MessageEvent.origin` when the URL is empty/malformed, so an - * unconfigured telephony server trusts nothing rather than everything. - */ -function safeOrigin(url: string): string { - try { - return new URL(url).origin; - } catch { - return 'invalid:no-telephony-origin'; - } -} - /** * Dashboard shell for the 104 agent desktop: navigation header, left rail, the * agent line / campaign selector, call statistics, the alerts, reports, @@ -167,44 +150,24 @@ function safeOrigin(url: string): string { }) export class DashboardComponent { private readonly authStore = inject(AuthStore); - private readonly callStore = inject(CallStore); - private readonly router = inject(Router); private readonly agentStatusApi = inject(AgentStatusService); private readonly dashboardStore = inject(DashboardStore); /** Hides the dev-only inbound-call simulator from production builds. */ readonly isProduction = environment.production; - /** Origin of the CZentrix telephony server, the only trusted CTI sender. */ - private readonly telephonyOrigin = safeOrigin(environment.telephoneServer); - /** The agent's live telephony state ("READY (IDLE)"), polled every 15 s. */ private readonly _agentStatus = signal(''); readonly agentStatus = this._agentStatus.asReadonly(); constructor() { - // The CZentrix CTI soft-phone iframe announces inbound calls to the host - // window via postMessage ("Accept|||INBOUND"). The dashboard - // is the agent's call-ready landing page, so it listens here, seeds the - // CallStore and routes into the guarded on-call workspace. - const onMessage = (event: MessageEvent): void => { - if (!this.isTrustedCtiOrigin(event.origin)) { - return; - } - this.handleCtiMessage(event.data); - }; - window.addEventListener('message', onMessage); - inject(DestroyRef).onDestroy(() => - window.removeEventListener('message', onMessage), - ); - // Poll the agent's telephony state (legacy DashboardUserIdComponent: // immediate call + every 15 s) to refresh the "My ID" status line and keep // the campaign selector in sync with the dialer mode. Supervisors have no // personal agent line, so they are never polled. A failed poll falls back // to the inbound campaign, as in the legacy error handler; the next tick - // retries. Inbound-call routing itself stays with the CTI postMessage flow - // above — the poller never navigates. + // retries. Inbound-call routing itself lives in the app-scoped + // InboundCtiService — the poller never navigates. timer(0, AGENT_STATUS_POLL_MS) .pipe( takeUntilDestroyed(), @@ -249,43 +212,11 @@ export class DashboardComponent { } } - /** - * Only accept CTI events from the CZentrix telephony origin — never from an - * arbitrary page/iframe that could forge an "inbound call". The dev simulator - * posts from this app's own origin, which is trusted in non-production builds. - */ - private isTrustedCtiOrigin(origin: string): boolean { - if (origin === this.telephonyOrigin) { - return true; - } - return !environment.production && origin === window.location.origin; - } - - /** Parse a CTI payload; on a fresh inbound call, seed state and navigate. */ - private handleCtiMessage(data: unknown): void { - const inbound = parseInboundCtiMessage(data); - if (!inbound) { - return; - } - // De-dupe: the iframe may re-post the same event for one connected call. - if ( - this.callStore.onCall() && - this.callStore.sessionId() === inbound.sessionId - ) { - return; - } - - this.callStore.startCall({ - cli: inbound.cli, - sessionId: inbound.sessionId, - }); - void this.router.navigate(['/innerpage']); - } - /** * Dev-only: post a fake inbound CTI event to this window so the inbound flow - * can be exercised locally without a live CZentrix soft-phone. Excluded from - * production builds via the {@link isProduction} template guard. + * (handled by the app-scoped InboundCtiService) can be exercised locally + * without a live CZentrix soft-phone. Excluded from production builds via + * the {@link isProduction} template guard. */ simulateInboundCall(): void { const cli = '9876543210'; diff --git a/src/app/app.ts b/src/app/app.ts index ab419b5..9d31162 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -20,11 +20,12 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { Component, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ZardToastComponent } from '@common-ui/ui/toast'; +import { InboundCtiService } from '@/app-modules/call/inbound-cti.service'; import { CtiPanelComponent } from '@/shared/components/layout/cti-panel.component'; @Component({ @@ -34,5 +35,9 @@ import { CtiPanelComponent } from '@/shared/components/layout/cti-panel.componen styleUrl: './app.css' }) export class App { + // Instantiated here so the inbound-call postMessage listener is registered + // for the whole app lifetime, matching the root-level persistent CTI iframe. + private readonly inboundCti = inject(InboundCtiService); + protected readonly title = signal('helpline104-next'); } diff --git a/src/app/shared/components/layout/cti-panel.component.ts b/src/app/shared/components/layout/cti-panel.component.ts index a38bdb0..2f70f4f 100644 --- a/src/app/shared/components/layout/cti-panel.component.ts +++ b/src/app/shared/components/layout/cti-panel.component.ts @@ -89,10 +89,12 @@ export class CtiPanelComponent { /** * Whether the CZentrix toggle is visible: an authenticated session with a - * selected role that is not the supervisor (who has no personal agent line). + * telephony agent id and a selected role that is not the supervisor (who has + * no personal agent line). Without an agent id the iframe has no CTI handler + * to load, so the toggle would only ever open an empty panel. */ readonly showCzentrix = computed(() => { - if (!this.authStore.isAuthenticated()) { + if (!this.authStore.isAuthenticated() || this.agentId() === null) { return false; } const featureCode = this.authStore.currentRole()?.featureCode ?? null; From 0b63050cdbfdd9ccb16c0a174743fd1a3ba5ed11 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 11:45:53 +0530 Subject: [PATCH 3/8] fix(closure): guard getTransferCampaigns against non-array response Co-Authored-By: Claude Fable 5 --- src/app/app-modules/call/hao/hao.service.ts | 10 ++++++++-- .../call/hao/steps/closure-step.component.ts | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/app-modules/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts index 0c863bc..616b1a9 100644 --- a/src/app/app-modules/call/hao/hao.service.ts +++ b/src/app/app-modules/call/hao/hao.service.ts @@ -171,14 +171,20 @@ export class HaoService { // --- Transfer (CTI) ----------------------------------------------------- - /** Campaigns the active call may be transferred to. */ + /** + * Campaigns the active call may be transferred to. + * + * UAT returns 200 with a non-array `data` payload for this endpoint, which + * the `@for` over the campaign list cannot iterate — anything that is not an + * array is treated as "no campaigns". + */ getTransferCampaigns(agentID: number): Observable { return this.http .post>( this.baseCommon + PATHS.transferCampaigns, { agent_id: agentID }, ) - .pipe(map((res) => res.data ?? [])); + .pipe(map((res) => (Array.isArray(res.data) ? res.data : []))); } /** Skills available within a chosen transfer campaign (keyed by name). */ diff --git a/src/app/app-modules/call/hao/steps/closure-step.component.ts b/src/app/app-modules/call/hao/steps/closure-step.component.ts index 22c9005..3e6a0a9 100644 --- a/src/app/app-modules/call/hao/steps/closure-step.component.ts +++ b/src/app/app-modules/call/hao/steps/closure-step.component.ts @@ -601,7 +601,10 @@ export class ClosureStepComponent { return; } this.haoService.getTransferCampaigns(agentID).subscribe({ - next: (campaigns) => this.campaigns.set(campaigns), + // Belt-and-braces: the template's @for iterates this signal, so a + // non-array value (misbehaving backend, stale mock) must never land. + next: (campaigns) => + this.campaigns.set(Array.isArray(campaigns) ? campaigns : []), error: () => this.campaigns.set([]), }); } From 607ebb2693874b20b5647b347f1a5cd079bbbe82 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 11:51:24 +0530 Subject: [PATCH 4/8] fix(hao): apply Array.isArray guard to getCampaignSkills and getCallTypes Co-Authored-By: Claude Fable 5 --- src/app/app-modules/call/hao/hao.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/app-modules/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts index 616b1a9..8b196f4 100644 --- a/src/app/app-modules/call/hao/hao.service.ts +++ b/src/app/app-modules/call/hao/hao.service.ts @@ -159,7 +159,7 @@ export class HaoService { providerServiceMapID: serviceID, ...(isInbound ? { isInbound: true } : { isOutbound: true }), }) - .pipe(map((res) => res.data ?? [])); + .pipe(map((res) => (Array.isArray(res.data) ? res.data : []))); } /** Record the call disposition and close the call. */ @@ -193,7 +193,7 @@ export class HaoService { .post>(this.baseCommon + PATHS.campaignSkills, { campaign_name: campaignName, }) - .pipe(map((res) => res.data ?? [])); + .pipe(map((res) => (Array.isArray(res.data) ? res.data : []))); } /** From f49814d7d34ab09d032220e9dee783b165ce1bd1 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 13:10:03 +0530 Subject: [PATCH 5/8] fix(cti): gate inbound postMessage handler on CTI-eligible auth state Co-Authored-By: Claude Fable 5 --- .../app-modules/call/inbound-cti.service.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/app/app-modules/call/inbound-cti.service.ts b/src/app/app-modules/call/inbound-cti.service.ts index 3480aeb..9fc7adc 100644 --- a/src/app/app-modules/call/inbound-cti.service.ts +++ b/src/app/app-modules/call/inbound-cti.service.ts @@ -24,9 +24,14 @@ import { DestroyRef, Injectable, inject } from '@angular/core'; import { Router } from '@angular/router'; import { environment } from '@env/environment'; +import { AuthStore } from '../core/auth/auth.store'; + import { CallStore } from './call.store'; import { parseInboundCtiMessage } from './cti-message'; +/** Feature code of the supervising role, which has no personal agent line. */ +const SUPERVISOR_FEATURE_CODE = 'Supervisor'; + /** * Extract the origin from a configured base URL. Returns a token that can never * equal a real `MessageEvent.origin` when the URL is empty/malformed, so an @@ -54,6 +59,7 @@ function safeOrigin(url: string): string { */ @Injectable({ providedIn: 'root' }) export class InboundCtiService { + private readonly authStore = inject(AuthStore); private readonly callStore = inject(CallStore); private readonly router = inject(Router); @@ -62,7 +68,7 @@ export class InboundCtiService { constructor() { const onMessage = (event: MessageEvent): void => { - if (!this.isTrustedCtiOrigin(event.origin)) { + if (!this.isTrustedCtiOrigin(event.origin) || !this.isCtiEligible()) { return; } this.handleCtiMessage(event.data); @@ -85,6 +91,25 @@ export class InboundCtiService { return !environment.production && origin === window.location.origin; } + /** + * Whether the current session may take inbound CTI calls — mirrors the + * CtiPanelComponent `showCzentrix` gate: an authenticated user with a + * telephony agent id and a selected non-supervisor role. The listener stays + * registered for the app's lifetime, so without this gate a message arriving + * on the login screen or in a supervisor session would seed call state and + * navigate into the on-call workspace. + */ + private isCtiEligible(): boolean { + if ( + !this.authStore.isAuthenticated() || + (this.authStore.user()?.agentID ?? null) === null + ) { + return false; + } + const featureCode = this.authStore.currentRole()?.featureCode ?? null; + return featureCode !== null && featureCode !== SUPERVISOR_FEATURE_CODE; + } + /** Parse a CTI payload; on a fresh inbound call, seed state and navigate. */ private handleCtiMessage(data: unknown): void { const inbound = parseInboundCtiMessage(data); From c20a6a6e578ac2f9e013500d8a2b414a3c00cf89 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 16:22:16 +0530 Subject: [PATCH 6/8] fix(hao): throw on malformed getCallTypes response so error handler fires Co-Authored-By: Claude Fable 5 --- src/app/app-modules/call/hao/hao.service.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/app-modules/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts index 8b196f4..a45b05c 100644 --- a/src/app/app-modules/call/hao/hao.service.ts +++ b/src/app/app-modules/call/hao/hao.service.ts @@ -159,7 +159,19 @@ export class HaoService { providerServiceMapID: serviceID, ...(isInbound ? { isInbound: true } : { isOutbound: true }), }) - .pipe(map((res) => (Array.isArray(res.data) ? res.data : []))); + .pipe( + // Call types are mandatory for closure — a malformed payload must hit + // the caller's error path (visible to the agent), not render an empty + // dropdown that silently strands the call. + map((res) => { + if (!Array.isArray(res.data)) { + throw new Error( + 'getCallTypes: expected array, got ' + typeof res.data, + ); + } + return res.data; + }), + ); } /** Record the call disposition and close the call. */ From 223946d7a53ef454a4962f0c70a87e1bb2f0aea8 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 17:44:50 +0530 Subject: [PATCH 7/8] =?UTF-8?q?fix(hao):=20null-safe=20getCallTypes=20?= =?UTF-8?q?=E2=80=94=20null=20returns=20empty,=20non-array=20throws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/app/app-modules/call/hao/hao.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/app-modules/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts index a45b05c..9f1ea44 100644 --- a/src/app/app-modules/call/hao/hao.service.ts +++ b/src/app/app-modules/call/hao/hao.service.ts @@ -160,10 +160,12 @@ export class HaoService { ...(isInbound ? { isInbound: true } : { isOutbound: true }), }) .pipe( - // Call types are mandatory for closure — a malformed payload must hit - // the caller's error path (visible to the agent), not render an empty - // dropdown that silently strands the call. + // Call types are mandatory for closure: an absent payload (null / + // undefined) is a legitimate empty catalogue, but any other non-array + // shape is malformed and must hit the caller's error path (visible to + // the agent), not render an empty dropdown that strands the call. map((res) => { + if (res.data == null) return []; if (!Array.isArray(res.data)) { throw new Error( 'getCallTypes: expected array, got ' + typeof res.data, From 6187442f02302e83e2bb1be4e21d22cab324bf70 Mon Sep 17 00:00:00 2001 From: Aarti Panchal Date: Sat, 25 Jul 2026 18:23:23 +0530 Subject: [PATCH 8/8] fix(registration): persistent error banner on beneficiary/create failure Co-Authored-By: Claude Fable 5 --- .../beneficiary-registration.component.ts | 26 +++++++++++++++++++ src/app/app-modules/core/i18n/locales/as.ts | 3 +++ src/app/app-modules/core/i18n/locales/en.ts | 3 +++ src/app/app-modules/core/i18n/locales/hi.ts | 3 +++ 4 files changed, 35 insertions(+) diff --git a/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts b/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts index 9dd8abb..f5e6960 100644 --- a/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts +++ b/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts @@ -981,6 +981,24 @@ function validDob(control: AbstractControl): ValidationErrors | null { + + @if (registerError(); as msg) { + + } } @@ -1070,6 +1088,12 @@ export class BeneficiaryRegistrationComponent implements OnInit { private searchRequestId = 0; readonly registerLoading = signal(false); + /** + * Persistent register-failure banner. The toast alone disappears before the + * agent can act on it, leaving a silent dead-end; this stays visible until + * the next submit attempt (or is dismissed). + */ + readonly registerError = signal(null); // --- Register-form view state ------------------------------------------- readonly page = signal<1 | 2>(1); @@ -1584,6 +1608,7 @@ export class BeneficiaryRegistrationComponent implements OnInit { } doRegister(): void { + this.registerError.set(null); // Hard guard: a disabled form reports as valid, so this must run before the // invalid-check below to stop a submit with an empty phoneNo. if (this.cliMissing() || !this.callStore.cli()) { @@ -1683,6 +1708,7 @@ export class BeneficiaryRegistrationComponent implements OnInit { }, error: (err: BeneficiaryError) => { this.registerLoading.set(false); + this.registerError.set(this.i18n.instant('registration.register.error')); toast.error( err?.errorMessage || this.i18n.instant('registration.toast.error'), ); diff --git a/src/app/app-modules/core/i18n/locales/as.ts b/src/app/app-modules/core/i18n/locales/as.ts index b5b709e..df61a8e 100644 --- a/src/app/app-modules/core/i18n/locales/as.ts +++ b/src/app/app-modules/core/i18n/locales/as.ts @@ -167,6 +167,9 @@ export const as: Record = { 'registration.toast.selected': 'এই কলৰ বাবে হিতাধিকাৰী বাছনি কৰা হ’ল।', 'registration.toast.registered': 'হিতাধিকাৰী পঞ্জীয়ন আৰু বাছনি কৰা হ’ল।', 'registration.toast.error': 'কিবা ভুল হ’ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক।', + 'registration.register.error': + "পঞ্জীয়ন বিফল হ'ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক বা সহায়ৰ সৈতে যোগাযোগ কৰক।", + 'registration.register.dismiss': 'বন্ধ কৰক', 'registration.toast.noCli': 'এই কলৰ বাবে কোনো কলাৰ নম্বৰ উপলব্ধ নহয়।', 'registration.toast.masterError': 'কিছুমান ফৰ্ম বিকল্প ল’ড কৰিব পৰা নগ’ল।', 'registration.notice.noCli': diff --git a/src/app/app-modules/core/i18n/locales/en.ts b/src/app/app-modules/core/i18n/locales/en.ts index f3da285..e3ab250 100644 --- a/src/app/app-modules/core/i18n/locales/en.ts +++ b/src/app/app-modules/core/i18n/locales/en.ts @@ -165,6 +165,9 @@ export const en = { 'registration.toast.selected': 'Beneficiary selected for this call.', 'registration.toast.registered': 'Beneficiary registered and selected.', 'registration.toast.error': 'Something went wrong. Please try again.', + 'registration.register.error': + 'Registration failed. Please try again or contact support.', + 'registration.register.dismiss': 'Close', 'registration.toast.noCli': 'No caller number is available for this call.', 'registration.toast.masterError': 'Could not load some form options.', 'registration.notice.noCli': diff --git a/src/app/app-modules/core/i18n/locales/hi.ts b/src/app/app-modules/core/i18n/locales/hi.ts index b9d73e8..43eeb1f 100644 --- a/src/app/app-modules/core/i18n/locales/hi.ts +++ b/src/app/app-modules/core/i18n/locales/hi.ts @@ -167,6 +167,9 @@ export const hi: Record = { 'registration.toast.selected': 'इस कॉल के लिए लाभार्थी चुना गया।', 'registration.toast.registered': 'लाभार्थी पंजीकृत और चयनित किया गया।', 'registration.toast.error': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।', + 'registration.register.error': + 'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें या सहायता से संपर्क करें।', + 'registration.register.dismiss': 'बंद करें', 'registration.toast.noCli': 'इस कॉल के लिए कोई कॉलर नंबर उपलब्ध नहीं है।', 'registration.toast.masterError': 'कुछ फ़ॉर्म विकल्प लोड नहीं हो सके।', 'registration.notice.noCli':