Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,24 @@ function validDob(control: AbstractControl): ValidationErrors | null {
</div>
</div>
</form>

@if (registerError(); as msg) {
<div
class="mt-5 flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive"
role="alert"
>
<span>{{ msg }}</span>
<button
z-button
type="button"
zType="ghost"
zSize="sm"
(click)="registerError.set(null)"
>
{{ 'registration.register.dismiss' | translate: lang() }}
</button>
</div>
}
}
</section>

Expand Down Expand Up @@ -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<string | null>(null);

// --- Register-form view state -------------------------------------------
readonly page = signal<1 | 2>(1);
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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'),
);
Expand Down
28 changes: 24 additions & 4 deletions src/app/app-modules/call/hao/hao.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
);
}

/** Record the call disposition and close the call. */
Expand All @@ -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<TransferCampaign[]> {
return this.http
.post<ApiResponse<TransferCampaign[]>>(
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). */
Expand All @@ -187,7 +207,7 @@ export class HaoService {
.post<ApiResponse<CampaignSkill[]>>(this.baseCommon + PATHS.campaignSkills, {
campaign_name: campaignName,
})
.pipe(map((res) => res.data ?? []));
.pipe(map((res) => (Array.isArray(res.data) ? res.data : [])));
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/app/app-modules/call/hao/steps/closure-step.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
});
}
Expand Down
133 changes: 133 additions & 0 deletions src/app/app-modules/call/inbound-cti.service.ts
Original file line number Diff line number Diff line change
@@ -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|<CLI>|<sessionId>|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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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']);
}
}
3 changes: 3 additions & 0 deletions src/app/app-modules/core/i18n/locales/as.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export const as: Record<TranslationKey, string> = {
'registration.toast.selected': 'এই কলৰ বাবে হিতাধিকাৰী বাছনি কৰা হ’ল।',
'registration.toast.registered': 'হিতাধিকাৰী পঞ্জীয়ন আৰু বাছনি কৰা হ’ল।',
'registration.toast.error': 'কিবা ভুল হ’ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক।',
'registration.register.error':
"পঞ্জীয়ন বিফল হ'ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক বা সহায়ৰ সৈতে যোগাযোগ কৰক।",
'registration.register.dismiss': 'বন্ধ কৰক',
'registration.toast.noCli': 'এই কলৰ বাবে কোনো কলাৰ নম্বৰ উপলব্ধ নহয়।',
'registration.toast.masterError': 'কিছুমান ফৰ্ম বিকল্প ল’ড কৰিব পৰা নগ’ল।',
'registration.notice.noCli':
Expand Down
3 changes: 3 additions & 0 deletions src/app/app-modules/core/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
3 changes: 3 additions & 0 deletions src/app/app-modules/core/i18n/locales/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export const hi: Record<TranslationKey, string> = {
'registration.toast.selected': 'इस कॉल के लिए लाभार्थी चुना गया।',
'registration.toast.registered': 'लाभार्थी पंजीकृत और चयनित किया गया।',
'registration.toast.error': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।',
'registration.register.error':
'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें या सहायता से संपर्क करें।',
'registration.register.dismiss': 'बंद करें',
'registration.toast.noCli': 'इस कॉल के लिए कोई कॉलर नंबर उपलब्ध नहीं है।',
'registration.toast.masterError': 'कुछ फ़ॉर्म विकल्प लोड नहीं हो सके।',
'registration.notice.noCli':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<app-shell-footer>
Expand All @@ -69,56 +55,16 @@ const FEEDBACK_ROUTE = '/feedback';
<ng-icon name="lucideMail" size="14" aria-hidden="true" />
{{ 'dashboard.footer.feedback' | translate: lang() }}
</button>

@if (showCzentrix()) {
<button z-button type="button" zSize="sm" (click)="toggleCti()">
{{ czentrixLabel }}
</button>
}
</app-shell-footer>

@if (showCzentrix() && ctiOpen() && ctiUrl(); as src) {
<iframe
[src]="src"
[title]="czentrixLabel"
class="fixed bottom-12 right-4 z-50 h-[380px] w-[230px] rounded-md border border-border bg-card shadow-lg"
></iframe>
}
`,
})
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<number | null>(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<SafeResourceUrl | null>(() => {
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();
Expand Down
Loading