Files
cbsnexora/src/app/clients/services/external-national-id.service.ts
T

309 lines
10 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { inject, Injectable, OnDestroy } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ClientsService } from '../clients.service';
import {
EXTERNAL_ID_STATUS_KEYS,
EXTERNAL_GENDER_MAP,
PERSON_FIELD_NAMES,
type PersonFieldName,
type ExternalIdStatusKey
} from './external-national-id.constants';
/** Response shape from the external National ID API */
export interface ExternalNationalIdResponse {
firstname?: string;
middlename?: string;
lastname?: string;
dateOfBirth?: string;
dateFormat?: string;
genderId?: number;
developerMessage?: string;
}
/** Fineract gender option (from client template) */
export interface GenderOption {
id: number;
name: string;
active?: boolean;
mandatory?: boolean;
}
/**
* Shared service for External National ID System integration.
*
* Provided at the component level (not `providedIn: 'root'`) so each component
* gets its own instance with independent lifecycle. The service's `ngOnDestroy`
* is called when the host component is destroyed, cleaning up subscriptions.
*
* When enabled, watches the externalId form control:
* - Validates input against configured regex (e.g. CURP)
* - Calls external API to lookup client data
* - Auto-fills firstname, middlename, lastname, dateOfBirth, genderId
* - Disables those fields so users cannot modify API-provided values
*/
@Injectable()
export class ExternalNationalIdService implements OnDestroy {
/** Whether the feature is enabled via env var */
readonly enabled: boolean;
/**
* Compiled regex pattern for external ID validation.
* Compiled once in the constructor to avoid repeated `new RegExp()` calls (CWE-1333 mitigation).
* Null if regex is not configured or is invalid.
*/
private readonly pattern: RegExp | null;
/** Status message key for the template to display */
statusMessageKey: ExternalIdStatusKey = EXTERNAL_ID_STATUS_KEYS.EMPTY;
/** Whether a lookup is currently in progress */
isLoading = false;
private destroy$ = new Subject<void>();
private clientsService = inject(ClientsService);
constructor() {
this.enabled = environment.enableExternalNationalIdSystem === true;
const regexStr = environment.externalNationalIdRegex;
if (regexStr) {
try {
this.pattern = new RegExp(regexStr);
} catch {
console.warn('[ExternalNationalId] Invalid regex pattern:', regexStr);
this.pattern = null;
}
} else {
this.pattern = null;
}
}
/**
* Validates an external ID against the compiled regex pattern.
*/
isValidExternalId(value: string): boolean {
if (!value || !this.pattern) {
return false;
}
return this.pattern.test(value.trim());
}
/**
* Start watching the externalId form control for changes.
* When a valid ID is entered, calls the external API and fills form fields.
*
* @param form The reactive form containing externalId and person fields
* @param genderOptions Array of gender options from the Fineract template
* @param skipInitialValue If true, skips lookup for the current form value (used in edit mode
* to avoid re-fetching data for an already-saved external ID)
*/
watchExternalId(form: UntypedFormGroup, genderOptions: GenderOption[], skipInitialValue = false): void {
if (!this.enabled) {
return;
}
const externalIdCtrl = form.get('externalId');
if (!externalIdCtrl) {
return;
}
// In edit mode, if the client already has a valid external ID, lock the fields
// without making an API call
if (skipInitialValue && externalIdCtrl.value && this.isValidExternalId(externalIdCtrl.value)) {
this.disablePersonFields(form);
return;
}
externalIdCtrl.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
filter((value: string) => {
if (!value || !this.isValidExternalId(value)) {
// Re-enable fields if ID is cleared or invalid
this.enablePersonFields(form);
if (value && value.length > 3 && !this.isValidExternalId(value)) {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT;
} else {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.EMPTY;
}
return false;
}
return true;
}),
switchMap((value: string) => {
this.isLoading = true;
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.LOADING;
return this.clientsService.lookupExternalNationalId(value).pipe(
timeout(10000),
catchError((error) => {
this.isLoading = false;
if (error.name === 'TimeoutError') {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.TIMEOUT;
} else if (error.status === 404) {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND;
} else {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.FAILED;
}
this.enablePersonFields(form);
return of(null);
})
);
}),
takeUntil(this.destroy$)
)
.subscribe((response: ExternalNationalIdResponse | null) => {
this.isLoading = false;
if (response) {
this.fillFormFromResponse(form, response, genderOptions);
}
});
}
/**
* Fill form fields from the external API response and disable them.
* Guards each control access to handle the case where the legal form changes
* while a lookup is in-flight (e.g. controls removed by buildDependencies).
*/
private fillFormFromResponse(
form: UntypedFormGroup,
response: ExternalNationalIdResponse,
genderOptions: GenderOption[]
): void {
const devMessage = response.developerMessage || '';
if (devMessage === 'client.externalid.notfound') {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND;
this.enablePersonFields(form);
return;
}
if (devMessage === 'client.externalid.invalid') {
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT;
this.enablePersonFields(form);
return;
}
// Valid response — fill and disable fields
this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS;
const firstnameCtrl = form.get('firstname');
const middlenameCtrl = form.get('middlename');
const lastnameCtrl = form.get('lastname');
const dobCtrl = form.get('dateOfBirth');
const genderCtrl = form.get('genderId');
// Guard: if person-specific controls were removed (legal form changed to Entity
// while lookup was in-flight), skip those fields gracefully
if (firstnameCtrl && response.firstname) {
firstnameCtrl.setValue(response.firstname);
firstnameCtrl.disable();
}
if (middlenameCtrl && response.middlename) {
middlenameCtrl.setValue(response.middlename);
middlenameCtrl.disable();
}
if (lastnameCtrl && response.lastname) {
lastnameCtrl.setValue(response.lastname);
lastnameCtrl.disable();
}
if (dobCtrl && response.dateOfBirth) {
const parsedDate = this.parseDate(response.dateOfBirth, response.dateFormat);
if (parsedDate) {
dobCtrl.setValue(parsedDate);
dobCtrl.disable();
}
}
if (genderCtrl && response.genderId != null) {
const genderName = EXTERNAL_GENDER_MAP[response.genderId];
const matchedGender = genderName
? genderOptions?.find((g: GenderOption) => g.name?.toLowerCase() === genderName.toLowerCase())
: null;
if (matchedGender) {
genderCtrl.setValue(matchedGender.id);
genderCtrl.disable();
}
}
}
/**
* Parse a date string using the provided format.
* Validates the parsed result against the expected format to catch mismatches.
*
* @param dateStr The date string from the API (e.g. "15/03/1990")
* @param dateFormat The format hint from the API (e.g. "dd/MM/yyyy")
* @returns A valid Date object or null if parsing fails
*/
parseDate(dateStr: string, dateFormat?: string): Date | null {
if (!dateStr) {
return null;
}
const format = dateFormat || 'dd/MM/yyyy';
const parts = dateStr.split('/');
if (format === 'dd/MM/yyyy' && parts.length === 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const year = parseInt(parts[2], 10);
// Validate ranges before constructing
if (day < 1 || day > 31 || month < 0 || month > 11 || year < 1900 || year > 2100) {
return null;
}
const date = new Date(year, month, day);
// Verify the date components match (catches invalid dates like Feb 30)
if (isNaN(date.getTime()) || date.getDate() !== day || date.getMonth() !== month || date.getFullYear() !== year) {
return null;
}
return date;
}
// Fallback: try native parsing
const fallback = new Date(dateStr);
return isNaN(fallback.getTime()) ? null : fallback;
}
/**
* Re-enable person fields so they can be edited manually.
*/
enablePersonFields(form: UntypedFormGroup): void {
for (const field of PERSON_FIELD_NAMES) {
form.get(field)?.enable();
}
}
/**
* Disable person fields (used in edit mode to lock fields for existing external IDs).
*/
private disablePersonFields(form: UntypedFormGroup): void {
for (const field of PERSON_FIELD_NAMES) {
form.get(field)?.disable();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}