309 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|