WEB-600 feat:External National ID System integration for client creation/editing (#3145)
improve
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user