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/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts index 0c863bc..9f1ea44 100644 --- a/src/app/app-modules/call/hao/hao.service.ts +++ b/src/app/app-modules/call/hao/hao.service.ts @@ -159,7 +159,21 @@ export class HaoService { providerServiceMapID: serviceID, ...(isInbound ? { isInbound: true } : { isOutbound: true }), }) - .pipe(map((res) => res.data ?? [])); + .pipe( + // 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, + ); + } + return res.data; + }), + ); } /** Record the call disposition and close the call. */ @@ -171,14 +185,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). */ @@ -187,7 +207,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 : []))); } /** 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([]), }); } 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..9fc7adc --- /dev/null +++ b/src/app/app-modules/call/inbound-cti.service.ts @@ -0,0 +1,133 @@ +/* + * 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 { 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 + * 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 authStore = inject(AuthStore); + 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) || !this.isCtiEligible()) { + 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; + } + + /** + * 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); + 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/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': 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..e0a23d9 100644 --- a/src/app/app-modules/dashboard/dashboard.component.ts +++ b/src/app/app-modules/dashboard/dashboard.component.ts @@ -23,21 +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 { 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'; import { AgentState, AgentStatusService } from './agent-status.service'; import { AgentIdComponent } from './components/agent-id.component'; @@ -75,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, @@ -114,7 +95,6 @@ function safeOrigin(url: string): string { ReportsPanelComponent, ActivityPanelComponent, RatingPanelComponent, - ZardButtonComponent, ], template: `
@@ -154,15 +134,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 + * 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() || this.agentId() === null) { + 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); + } +}