diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index 73f53129a40..c1602a78e1f 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -25,6 +25,7 @@
"@ngrx/effects": "^17.1.1",
"@ngrx/store": "^17.1.1",
"@ngx-translate/core": "^16.0.3",
+ "@types/levenshtein": "1.0.4",
"@webcomponents/webcomponentsjs": "^2.8.0",
"bikram-sambat-bootstrap": "^1.6.0",
"bootstrap": "^3.4.1",
@@ -34,6 +35,7 @@
"eurodigit": "^3.1.3",
"font-awesome": "^4.7.0",
"jquery": "3.5.1",
+ "levenshtein": "1.0.5",
"lodash-es": "^4.17.21",
"moment-locales-webpack-plugin": "^1.2.0",
"ngrx-store-logger": "^0.2.4",
@@ -4808,6 +4810,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/levenshtein": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz",
+ "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==",
+ "license": "MIT"
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -8687,6 +8695,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/levenshtein": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz",
+ "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==",
+ "engines": [
+ "node >=0.2.0"
+ ],
+ "license": "Public Domain"
+ },
"node_modules/license-webpack-plugin": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz",
@@ -16839,6 +16856,11 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
+ "@types/levenshtein": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz",
+ "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA=="
+ },
"@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -19648,6 +19670,11 @@
"integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==",
"dev": true
},
+ "levenshtein": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz",
+ "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA=="
+ },
"license-webpack-plugin": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz",
diff --git a/webapp/package.json b/webapp/package.json
index ad807a7194d..00660774e8c 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -61,7 +61,9 @@
"select2": "4.0.3",
"signature_pad": "2.3.x",
"tslib": "^2.5.3",
- "zone.js": "^0.14.4"
+ "zone.js": "^0.14.4",
+ "levenshtein": "1.0.5",
+ "@types/levenshtein": "1.0.4"
},
"overrides": {
"minimist": ">=1.2.6"
diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less
index f4801f4a7df..0785b782389 100644
--- a/webapp/src/css/enketo/medic.less
+++ b/webapp/src/css/enketo/medic.less
@@ -443,6 +443,58 @@
.pages.or .or-repeat-info[role="page"] {
display: block;
}
+
+ #duplicate_info {
+ width: 100%;
+ min-height: 20px;
+ padding-left: 20px;
+ padding-right: 20px;
+ background-color: #ffe7e8;
+
+ .results_header {
+ font-size: large;
+ color: #e33030;
+ }
+
+ .acknowledge_label {
+ -webkit-user-select: none; -ms-user-select: none; user-select: none;
+ }
+
+ .acknowledge_checkbox {
+ margin-right: 5px;
+ }
+
+ .divider {
+ background-color: #e33030;
+ height: 1px;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ }
+
+ .card {
+ border: 1px solid #ddd;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ border-radius: 5px;
+ }
+
+ .nested-section {
+ margin-left: 1.5rem;
+ }
+
+ .toggle-button {
+ background: none;
+ border: none;
+ color: #007bff;
+ cursor: pointer;
+ font-weight: bold;
+ padding-left: 0px;
+ }
+
+ .toggle-button:hover {
+ text-decoration: underline;
+ }
+ }
}
@media (max-width: @media-mobile) {
diff --git a/webapp/src/ts/components/components.module.ts b/webapp/src/ts/components/components.module.ts
index 0c930b97e08..c26a5c85a79 100644
--- a/webapp/src/ts/components/components.module.ts
+++ b/webapp/src/ts/components/components.module.ts
@@ -47,6 +47,7 @@ import { PanelHeaderComponent } from '@mm-components/panel-header/panel-header.c
import { SidebarMenuComponent } from '@mm-components/sidebar-menu/sidebar-menu.component';
import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component';
import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component';
+import {DuplicateInfoComponent} from '@mm-components/duplicate-info/duplicate-info.component';
@NgModule({
declarations: [
@@ -78,6 +79,7 @@ import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/t
SidebarMenuComponent,
TrainingCardsFormComponent,
ToolBarComponent,
+ DuplicateInfoComponent,
],
imports: [
CommonModule,
@@ -122,6 +124,7 @@ import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/t
SidebarMenuComponent,
TrainingCardsFormComponent,
ToolBarComponent,
+ DuplicateInfoComponent,
]
})
export class ComponentsModule { }
diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.html b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html
new file mode 100644
index 00000000000..5e061f5f16a
--- /dev/null
+++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html
@@ -0,0 +1,53 @@
+
0 ? 'block' : 'none' }">
+
+
+
+
+
+
+
+
+
{{ 'Item number:' | translate }} {{ i + 1 }}
+
+
+ {{ 'Name:' | translate }} {{ duplicate.name }}
+
+ {{ 'Created on:' | translate }} {{ duplicate.reported_date | date: 'EEE MMM dd yyyy HH:mm:ss' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ key.key }}: {{ key.value }}
+
+
+
+
diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts
new file mode 100644
index 00000000000..844088f9226
--- /dev/null
+++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts
@@ -0,0 +1,40 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+ selector: 'mm-duplicate-info',
+ templateUrl: './duplicate-info.component.html',
+})
+export class DuplicateInfoComponent {
+ @Input() acknowledged: boolean = false;
+ @Output() acknowledgedChange = new EventEmitter();
+ @Output() navigateToDuplicate = new EventEmitter();
+ @Input() duplicates: { _id: string; name: string; reported_date: string | Date; [key: string]: string | Date }[] = [];
+
+ toggleAcknowledged() {
+ this.acknowledged = !this.acknowledged;
+ this.acknowledgedChange.emit(this.acknowledged);
+ }
+
+ _navigateToDuplicate(_id: string){
+ this.navigateToDuplicate.emit(_id);
+ }
+
+ // Handles collapse / expand of duplicate doc details
+ expandedSections = new Map();
+
+ toggleSection(path: string): void {
+ this.expandedSections.set(path, !this.expandedSections.get(path));
+ }
+
+ isExpanded(path: string): boolean {
+ return this.expandedSections.get(path) || false;
+ }
+
+ isObject(value: any): boolean {
+ return value && typeof value === 'object' && !Array.isArray(value);
+ }
+
+ getPath(parentPath: string, key: string): string {
+ return parentPath ? `${parentPath}.${key}` : key;
+ }
+}
diff --git a/webapp/src/ts/components/enketo/enketo.component.html b/webapp/src/ts/components/enketo/enketo.component.html
index 1a4f81d24b0..773c8d0b6b5 100644
--- a/webapp/src/ts/components/enketo/enketo.component.html
+++ b/webapp/src/ts/components/enketo/enketo.component.html
@@ -1,5 +1,6 @@
diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts
index e0407b7caf4..8e3cb82ee24 100644
--- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts
+++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts
@@ -5,7 +5,7 @@ import { isEqual as _isEqual } from 'lodash-es';
import { ActivatedRoute, Router } from '@angular/router';
import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service';
-import { FormService } from '@mm-services/form.service';
+import { FormService, DuplicatesFoundError, Duplicate } from '@mm-services/form.service';
import { EnketoFormContext } from '@mm-services/enketo.service';
import { ContactTypesService } from '@mm-services/contact-types.service';
import { DbService } from '@mm-services/db.service';
@@ -55,6 +55,18 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit {
private trackSave;
private trackMetadata = { action: '', form: '' };
+ private duplicateCheck;
+ acknowledged = false;
+ onAcknowledgeChange(value: boolean) {
+ this.acknowledged = value;
+ }
+
+ onNavigateToDuplicate(_id: string){
+ this.router.navigate(['/contacts', _id, 'edit']);
+ }
+
+ duplicates: Duplicate[] = [];
+
ngOnInit() {
this.trackRender = this.performanceService.track();
this.subscribeToStore();
@@ -153,6 +165,10 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit {
this.contentError = false;
this.errorTranslationKey = false;
+ // Reset when when navigated to duplicate
+ this.duplicates = [];
+ this.acknowledged = false;
+
try {
const contact = await this.getContact();
const contactTypeId = this.contactTypesService.getTypeId(contact) || this.routeSnapshot.params?.type;
@@ -272,6 +288,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit {
private async renderForm(formId: string, titleKey: string) {
const formDoc = await this.dbService.get().get(formId);
this.xmlVersion = formDoc.xmlVersion;
+ this.duplicateCheck = formDoc.context?.duplicate_check;
this.globalActions.setEnketoEditedStatus(false);
@@ -326,7 +343,9 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit {
$('form.or').trigger('beforesave');
return this.formService
- .saveContact(form, docId, this.enketoContact.type, this.xmlVersion)
+ .saveContact({
+ form, docId, type: this.enketoContact.type, xmlVersion: this.xmlVersion
+ }, this.duplicateCheck, this.acknowledged)
.then((result) => {
console.debug('saved contact', result);
@@ -345,6 +364,11 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit {
this.router.navigate(['/contacts', result.docId]);
})
.catch((err) => {
+ if (err instanceof DuplicatesFoundError){
+ this.duplicates = err.duplicates;
+ err = Error(err.message);
+ }
+
console.error('Error submitting form data', err);
this.globalActions.setEnketoSavingStatus(false);
diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts
index 2329c820407..65225f3f3d0 100644
--- a/webapp/src/ts/services/form.service.ts
+++ b/webapp/src/ts/services/form.service.ts
@@ -26,6 +26,9 @@ import { reduce as _reduce } from 'lodash-es';
import { ContactTypesService } from '@mm-services/contact-types.service';
import { TargetAggregatesService } from '@mm-services/target-aggregates.service';
import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service';
+import { ParseProvider } from '@mm-providers/parse.provider';
+import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service';
+import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck } from './utils/deduplicate';
/**
* Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the
@@ -58,6 +61,8 @@ export class FormService {
private enketoService: EnketoService,
private targetAggregatesService: TargetAggregatesService,
private contactViewModelGeneratorService: ContactViewModelGeneratorService,
+ private readonly parseProvider: ParseProvider,
+ private readonly xmlFormsDuplicateUtilsService: XmlFormsContextUtilsService,
) {
this.inited = this.init();
this.globalActions = new GlobalActions(store);
@@ -326,7 +331,39 @@ export class FormService {
}, null);
}
- async saveContact(form, docId, type, xmlVersion) {
+ async checkForDuplicates(doc, duplicateCheck, acknowledged) {
+ const parentId = doc ? doc.parent?._id : undefined;
+ const contactType = doc ? doc.contact_type ?? doc.type : undefined;
+ const siblings = await requestSiblings(this.dbService, parentId, contactType);
+ const expression = extractExpression(duplicateCheck);
+ const isCanonical = doc.is_canonical ? doc.is_canonical === 'true' : false;
+ acknowledged = acknowledged ?? false;
+
+ if (!isCanonical && expression && !acknowledged){
+ const duplicates = getDuplicates(
+ doc,
+ siblings,
+ {
+ expression,
+ parseProvider: this.parseProvider,
+ xmlFormsContextUtilsService: this.xmlFormsDuplicateUtilsService
+ }
+ );
+ return duplicates;
+ }
+ }
+
+ async saveContact(
+ contactInfo: {
+ form: any;
+ docId: string| undefined;
+ type: string | undefined;
+ xmlVersion: string | undefined;
+ },
+ duplicateCheck: DuplicateCheck,
+ acknowledged: boolean
+ ) {
+ const { form, docId, type, xmlVersion } = contactInfo;
const typeFields = this.contactTypesService.isHardcodedType(type)
? { type }
: { type: 'contact', contact_type: type };
@@ -335,6 +372,16 @@ export class FormService {
const preparedDocs = await this.applyTransitions(docs);
const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type);
+
+ const duplicates = await this.checkForDuplicates(
+ primaryDoc || preparedDocs.preparedDocs[0],
+ duplicateCheck,
+ acknowledged
+ );
+ if (duplicates && duplicates.length > 0){
+ throw new DuplicatesFoundError('Duplicates found', duplicates);
+ }
+
this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]);
const bulkDocsResult = await this.dbService.get().bulkDocs(preparedDocs.preparedDocs);
const failureMessage = this.generateFailureMessage(bulkDocsResult);
@@ -350,4 +397,14 @@ export class FormService {
this.enketoService.unload(form);
}
}
-
+export class DuplicatesFoundError extends Error {
+ duplicates: Duplicate[];
+
+ constructor(message: string, duplicates: Duplicate[]) {
+ super(message);
+ this.message = message;
+ this.duplicates = duplicates;
+ this.name = 'DuplicatesFoundError';
+ }
+}
+export type Duplicate = Doc;
diff --git a/webapp/src/ts/services/utils/deduplicate.ts b/webapp/src/ts/services/utils/deduplicate.ts
new file mode 100644
index 00000000000..2aa7a3f6233
--- /dev/null
+++ b/webapp/src/ts/services/utils/deduplicate.ts
@@ -0,0 +1,94 @@
+import * as Levenshtein from 'levenshtein';
+import { DbService } from '@mm-services/db.service';
+import { ParseProvider } from '@mm-providers/parse.provider';
+import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service';
+
+
+export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any };
+
+const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(3, current.name, existing.name)';
+
+// Normalize the distance by dividing by the length of the longer string.
+// This can make the metric more adaptable across different string lengths
+const normalizedLevenshteinEq = function (str1: string, str2: string) {
+ const distance = levenshteinEq(str1, str2);
+ const maxLen = Math.max(str1.length, str2.length);
+ return (maxLen === 0) ? 0 : (distance / maxLen);
+};
+
+// The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions)
+// required to change one string into another.
+const levenshteinEq = function (str1: string, str2: string): number {
+ return new Levenshtein(str1, str2).distance;
+};
+
+
+const requestSiblings = async function (dbService: DbService, parentId: string, contactType: string) {
+ const siblings: Doc[] = [];
+ const results = parentId && contactType && await dbService.get().query('medic-client/contacts_by_parent', {
+ startkey: [parentId, contactType],
+ endkey: [parentId, contactType, {}],
+ include_docs: true
+ });
+
+ if (results) {
+ // Desc order - reverse order by switching props
+ siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc)
+ .sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0)));
+ }
+ return siblings;
+};
+
+export type DuplicateCheck = { expression?: string; disabled?: boolean } | undefined;
+const extractExpression = function (duplicateCheck: DuplicateCheck) {
+ // eslint-disable-next-line eqeqeq
+ if (duplicateCheck != null) {
+ if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'expression')) {
+ return duplicateCheck.expression as string;
+ } else if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'disabled') && duplicateCheck.disabled) {
+ return null; // No duplicate check should be performed
+ }
+ }
+
+ return DEFAULT_CONTACT_DUPLICATE_EXPRESSION;
+};
+
+const getDuplicates = function (
+ doc: Doc,
+ siblings: Array,
+ config: {
+ expression: string;
+ parseProvider: ParseProvider;
+ xmlFormsContextUtilsService: XmlFormsContextUtilsService;
+ }
+) {
+ const { expression, parseProvider, xmlFormsContextUtilsService } = config;
+ // eslint-disable-next-line eqeqeq
+ const _siblings: Doc[] = siblings.filter((s) => !((doc._id != null && s._id === doc._id)));
+ // Remove the currently edited doc from the sibling list
+
+ const duplicates: Array = [];
+ for (const sibling of _siblings) {
+ const parsed = parseProvider.parse(expression);
+ const test = parsed(xmlFormsContextUtilsService, {
+ current: doc,
+ existing: sibling,
+ });
+ if (test) {
+ duplicates.push(sibling);
+ }
+ }
+
+ return duplicates;
+};
+
+export {
+ normalizedLevenshteinEq,
+ levenshteinEq,
+
+ requestSiblings,
+ extractExpression,
+ getDuplicates,
+
+ DEFAULT_CONTACT_DUPLICATE_EXPRESSION
+};
diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts
index a3d86cfc2aa..74e8f887946 100644
--- a/webapp/src/ts/services/xml-forms-context-utils.service.ts
+++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts
@@ -1,5 +1,6 @@
import * as moment from 'moment';
import { Injectable } from '@angular/core';
+import { normalizedLevenshteinEq, levenshteinEq } from './utils/deduplicate';
/**
* Util functions available to a form doc's `.context` function for checking if
@@ -31,4 +32,12 @@ export class XmlFormsContextUtilsService {
return this.getDateDiff(contact, 'years');
}
+ levenshteinEq(threshold: number, current: string, existing: string){
+ return current && existing ? levenshteinEq(current, existing) < threshold : false;
+ }
+
+ normalizedLevenshteinEq(threshold: number, current: string, existing: string){
+ return current && existing ? normalizedLevenshteinEq(current, existing) < threshold : false;
+ }
+
}
diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts
index 61c63b0d16c..cf43470bc9e 100644
--- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts
+++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts
@@ -709,7 +709,9 @@ describe('ContactsEdit component', () => {
expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]);
expect(setEnketoError.callCount).to.equal(1);
expect(formService.saveContact.callCount).to.equal(1);
- expect(formService.saveContact.args[0]).to.deep.equal([ form, null, 'clinic', undefined ]);
+ expect(formService.saveContact.args[0]).to.deep.equal([
+ { form, docId: null, type: 'clinic', xmlVersion: undefined }, undefined, false
+ ]);
expect(router.navigate.callCount).to.equal(1);
expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]);
});
@@ -744,7 +746,9 @@ describe('ContactsEdit component', () => {
expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]);
expect(setEnketoError.callCount).to.equal(1);
expect(formService.saveContact.callCount).to.equal(1);
- expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_person', 'person', undefined ]);
+ expect(formService.saveContact.args[0]).to.deep.equal(
+ [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, undefined, false ]
+ );
expect(router.navigate.callCount).to.equal(1);
expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]);
expect(performanceService.track.calledThrice).to.be.true;
@@ -792,7 +796,9 @@ describe('ContactsEdit component', () => {
expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]);
expect(setEnketoError.callCount).to.equal(1);
expect(formService.saveContact.callCount).to.equal(1);
- expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_patient', 'patient', undefined ]);
+ expect(formService.saveContact.args[0]).to.deep.equal(
+ [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, undefined, false ]
+ );
expect(router.navigate.callCount).to.equal(1);
expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]);
expect(performanceService.track.calledThrice).to.be.true;
diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts
index 5b8599081a5..eec8501a382 100644
--- a/webapp/tests/karma/ts/services/form.service.spec.ts
+++ b/webapp/tests/karma/ts/services/form.service.spec.ts
@@ -37,6 +37,9 @@ import * as FileManager from '../../../../src/js/enketo/file-manager.js';
import { TargetAggregatesService } from '@mm-services/target-aggregates.service';
import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service';
+import { ParseProvider } from '@mm-providers/parse.provider';
+import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service';
+
describe('Form service', () => {
// return a mock form ready for putting in #dbContent
const mockEnketoDoc = formInternalId => {
@@ -63,6 +66,7 @@ describe('Form service', () => {
let dbGetAttachment;
let dbGet;
let dbBulkDocs;
+ let dbQuery;
let ContactSummary;
let Form2Sms;
let UserContact;
@@ -94,12 +98,15 @@ describe('Form service', () => {
let extractLineageService;
let targetAggregatesService;
let contactViewModelGeneratorService;
+ let parserProvider;
+ let xmlFormsContextUtilsService;
beforeEach(() => {
enketoInit = sinon.stub();
dbGetAttachment = sinon.stub();
dbGet = sinon.stub();
dbBulkDocs = sinon.stub();
+ dbQuery = sinon.stub();
ContactSummary = sinon.stub();
Form2Sms = sinon.stub();
UserContact = sinon.stub();
@@ -163,13 +170,16 @@ describe('Form service', () => {
targetAggregatesService = { getTargetDocs: sinon.stub() };
contactViewModelGeneratorService = { loadReports: sinon.stub() };
+ parserProvider = sinon.stub();
+ xmlFormsContextUtilsService = sinon.stub();
+
TestBed.configureTestingModule({
providers: [
provideMockStore(),
{
provide: DbService,
useValue: {
- get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs })
+ get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery })
}
},
{ provide: ContactSummaryService, useValue: { get: ContactSummary } },
@@ -193,6 +203,8 @@ describe('Form service', () => {
{ provide: ExtractLineageService, useValue: extractLineageService },
{ provide: TargetAggregatesService, useValue: targetAggregatesService },
{ provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService },
+ { provide: ParseProvider, useValue: parserProvider},
+ { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }
],
});
@@ -1299,19 +1311,24 @@ describe('Form service', () => {
let extractLineageService;
let enketoTranslationService;
+ let parse;
+
beforeEach(() => {
extractLineageService = { extract: sinon.stub() };
enketoTranslationService = {
contactRecordToJs: sinon.stub(),
};
+ parse = sinon.stub();
+ parserProvider = { parse };
+
TestBed.configureTestingModule({
providers: [
provideMockStore(),
{
provide: DbService,
useValue: {
- get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs })
+ get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery })
}
},
{ provide: ContactSummaryService, useValue: { get: ContactSummary } },
@@ -1334,6 +1351,8 @@ describe('Form service', () => {
{ provide: TranslateService, useValue: translateService },
{ provide: TrainingCardsService, useValue: trainingCardsService },
{ provide: FeedbackService, useValue: feedbackService },
+ { provide: ParseProvider, useValue: parserProvider},
+ { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService },
],
});
@@ -1357,7 +1376,7 @@ describe('Form service', () => {
extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } });
return service
- .saveContact(form, docId, type)
+ .saveContact({form, docId, type})
.then(() => {
assert.equal(dbGet.callCount, 1);
assert.equal(dbGet.args[0][0], 'abc');
@@ -1391,7 +1410,7 @@ describe('Form service', () => {
extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } });
return service
- .saveContact(form, docId, type)
+ .saveContact({form, docId, type})
.then(() => {
assert.equal(dbGet.callCount, 1);
assert.equal(dbGet.args[0][0], 'abc');
@@ -1435,7 +1454,7 @@ describe('Form service', () => {
dbBulkDocs.resolves([]);
return service
- .saveContact(form, docId, type)
+ .saveContact({form, docId, type})
.then(() => {
assert.isTrue(dbBulkDocs.calledOnce);
@@ -1494,7 +1513,7 @@ describe('Form service', () => {
clock = sinon.useFakeTimers(5000);
return service
- .saveContact(form, docId, type)
+ .saveContact({form, docId, type})
.then(() => {
assert.equal(dbGet.callCount, 2);
assert.deepEqual(dbGet.args[0], ['main1']);
@@ -1543,7 +1562,7 @@ describe('Form service', () => {
clock = sinon.useFakeTimers(1000);
return service
- .saveContact(form, docId, type)
+ .saveContact({form, docId, type})
.then(() => {
assert.equal(dbGet.callCount, 1);
assert.equal(dbGet.args[0][0], 'abc');
@@ -1580,5 +1599,191 @@ describe('Form service', () => {
assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]);
});
});
+
+ it('should throw an error with duplicates found', async function () {
+ const form = { getDataStr: () => '' };
+ const docId = null;
+ const type = 'some-contact-type';
+
+ dbGet.resolves({ });
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } }
+ });
+ extractLineageService.extract.returns({ _id: 'parent1'});
+ transitionsService.applyTransitions.callsFake((docs) => {
+ docs[0].transitioned = true;
+ return Promise.resolve(docs);
+ });
+ dbQuery.resolves({
+ offset: 0,
+ rows: [
+ { id: 'sib1',
+ doc: {
+ _id: 'sib1',
+ name: 'Sibling1',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ {
+ id: 'sib2',
+ doc: {
+ _id: 'sib2',
+ name: 'Sibling2',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ ],
+ total_rows: 2
+ });
+ dbBulkDocs.resolves([]);
+ clock = sinon.useFakeTimers(1000);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef
+ parse.callsFake(() => (XmlFormsContextUtilsService, ctx) => true);
+ try {
+ await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false);
+ // Fail the test if no error is thrown
+ throw new Error('Expected saveContact to throw an error, but it did not.');
+ } catch (e) {
+ expect(e.message).to.include('Duplicates found');
+ expect(e.duplicates).to.have.lengthOf(2);
+ expect(e.duplicates).to.deep.equal([
+ {
+ _id: 'sib1',
+ name: 'Sibling1',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ },
+ {
+ _id: 'sib2',
+ name: 'Sibling2',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ ]);
+ }
+ });
+
+ it('should pass duplicate check when duplicates are acknowledged', async function () {
+ const form = { getDataStr: () => '' };
+ const docId = null;
+ const type = 'some-contact-type';
+
+ dbGet.resolves({ });
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } }
+ });
+ extractLineageService.extract.returns({ _id: 'parent1'});
+ transitionsService.applyTransitions.callsFake((docs) => {
+ docs[0].transitioned = true;
+ return Promise.resolve(docs);
+ });
+ dbQuery.resolves({
+ offset: 0,
+ rows: [
+ { id: 'sib1',
+ doc: {
+ _id: 'sib1',
+ name: 'Sibling1',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ {
+ id: 'sib2',
+ doc: {
+ _id: 'sib2',
+ name: 'Sibling2',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ ],
+ total_rows: 2
+ });
+ dbBulkDocs.resolves([]);
+ clock = sinon.useFakeTimers(1000);
+
+ await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, true);
+ assert.equal(transitionsService.applyTransitions.callCount, 1);
+ assert.deepEqual(transitionsService.applyTransitions.args[0], [[
+ {
+ _id: 'main1',
+ name: 'Main',
+ type: 'contact',
+ contact_type: type,
+ parent: { _id: 'parent1' },
+ reported_date: 1000,
+ contact: undefined,
+ transitioned: true
+ }
+ ]]);
+ });
+
+ it('should pass duplicate check when record is marked as canonical', async function () {
+ const form = { getDataStr: () => '' };
+ const docId = null;
+ const type = 'some-contact-type';
+
+ dbGet.resolves({ });
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' }, is_canonical: 'true' }
+ });
+ extractLineageService.extract.returns({ _id: 'parent1'});
+ transitionsService.applyTransitions.callsFake((docs) => {
+ docs[0].transitioned = true;
+ return Promise.resolve(docs);
+ });
+ dbQuery.resolves({
+ offset: 0,
+ rows: [
+ { id: 'sib1',
+ doc: {
+ _id: 'sib1',
+ name: 'Sibling1',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ {
+ id: 'sib2',
+ doc: {
+ _id: 'sib2',
+ name: 'Sibling2',
+ parent: { _id: 'parent1' },
+ type,
+ reported_date: 1736845534000
+ }
+ },
+ ],
+ total_rows: 2
+ });
+ dbBulkDocs.resolves([]);
+ clock = sinon.useFakeTimers(1000);
+
+ await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false);
+ assert.equal(transitionsService.applyTransitions.callCount, 1);
+ assert.deepEqual(transitionsService.applyTransitions.args[0], [[
+ {
+ _id: 'main1',
+ name: 'Main',
+ type: 'contact',
+ contact_type: type,
+ parent: { _id: 'parent1' },
+ reported_date: 1000,
+ contact: undefined,
+ transitioned: true,
+ is_canonical: 'true'
+ }
+ ]]);
+ });
});
});
diff --git a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts
new file mode 100644
index 00000000000..be5a6e89d3a
--- /dev/null
+++ b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts
@@ -0,0 +1,156 @@
+import { TestBed } from '@angular/core/testing';
+import sinon from 'sinon';
+import { expect } from 'chai';
+
+import { DbService } from '@mm-services/db.service';
+import { ParseProvider } from '@mm-providers/parse.provider';
+import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service';
+import {
+ normalizedLevenshteinEq,
+ levenshteinEq,
+ requestSiblings,
+ extractExpression,
+ DEFAULT_CONTACT_DUPLICATE_EXPRESSION,
+ getDuplicates,
+} from '../../../../../src/ts/services/utils/deduplicate';
+
+describe('Deduplicate', () => {
+ let dbService;
+ let query;
+
+ beforeEach(() => {
+ query = sinon.stub();
+ dbService = {
+ get: () => ({ query })
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: DbService, useValue: dbService }
+ ]
+ });
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ describe('normalizedLevenshteinEq', () => {
+ it('should return return a score of 3', () => {
+ // Score/distance / maxLength
+ // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string)
+ // ~ 0.42857142857142855
+ expect(normalizedLevenshteinEq('Test123', 'Test')).lessThanOrEqual(0.42857142857142855);
+ });
+ });
+
+ describe('levenshteinEq', () => {
+ it('should return return a score of 3', () => {
+ expect(levenshteinEq('Test123', 'Test')).to.equal(3);
+ });
+ });
+
+ describe('requestSiblings', () => {
+ it('should return results filtered by parent and contact type', async function () {
+ query.resolves({
+ offset: 0,
+ rows: [
+ { id: 'sib1', doc: { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' } },
+ { id: 'sib2', doc: { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }},
+ ],
+ total_rows: 6
+ });
+ const siblings = await requestSiblings(dbService, 'parent1', 'some_type');
+ expect(siblings.length).to.equal(2);
+ expect(siblings).to.deep.equal([
+ { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' },
+ { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' },
+ ]);
+ });
+ });
+
+ describe('extractExpression', () => {
+ it('should return a default expression when none is provided', () => {
+ expect(extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION);
+ });
+ });
+
+ describe('getDuplicates', () => {
+ let pipesService;
+ let parseProvider;
+ beforeEach(() => {
+ pipesService = {
+ getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])),
+ meta: sinon.stub(),
+ getInstance: sinon.stub(),
+ };
+ parseProvider = new ParseProvider(pipesService);
+ });
+
+ it('should return duplicates based on default matching', () => {
+ const doc = {
+ _id: 'new',
+ name: 'Test',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ };
+ const siblings = [
+ {
+ _id: 'sib1',
+ name: 'Test1',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ {
+ _id: 'sib2',
+ name: 'Test2',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ {
+ _id: 'sib3',
+ name: 'Test the things',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ {
+ _id: 'sib4',
+ name: 'Testimony',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ ];
+ const results = getDuplicates(
+ doc,
+ siblings,
+ {
+ expression: DEFAULT_CONTACT_DUPLICATE_EXPRESSION,
+ parseProvider,
+ xmlFormsContextUtilsService: new XmlFormsContextUtilsService()
+ }
+ );
+ expect(results.length).equal(2);
+ expect(results).to.deep.equal([
+ {
+ _id: 'sib1',
+ name: 'Test1',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ {
+ _id: 'sib2',
+ name: 'Test2',
+ parent: { _id: 'parent1' },
+ contact_type: 'some_type',
+ reported_date: 1736845534000
+ },
+ ]);
+ });
+ });
+});