diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 24513b00..8eba4a97 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -20,20 +20,20 @@ module.exports = { }; const mergeContacts = async (options, db) => { - trace(`Fetching contact details: ${options.winnerId}`); - const winnerDoc = await Shared.fetch.contact(db, options.winnerId); + trace(`Fetching contact details: ${options.keptId}`); + const keptDoc = await Shared.fetch.contact(db, options.keptId); - const constraints = await lineageConstraints(db, winnerDoc); - const loserDocs = await Shared.fetch.contactList(db, options.loserIds); - await validateContacts(loserDocs, constraints); + const constraints = await lineageConstraints(db, keptDoc); + const removedDocs = await Shared.fetch.contactList(db, options.removedIds); + await validateContacts(removedDocs, constraints); let affectedContactCount = 0, affectedReportCount = 0; - const replacementLineage = lineageManipulation.createLineageFromDoc(winnerDoc); - for (let loserId of options.loserIds) { - const contactDoc = loserDocs[loserId]; - const descendantsAndSelf = await Shared.fetch.descendantsOf(db, loserId); + const replacementLineage = lineageManipulation.createLineageFromDoc(keptDoc); + for (let removedId of options.removedIds) { + const contactDoc = removedDocs[removedId]; + const descendantsAndSelf = await Shared.fetch.descendantsOf(db, removedId); - const self = descendantsAndSelf.find(d => d._id === loserId); + const self = descendantsAndSelf.find(d => d._id === removedId); Shared.writeDocumentToDisk(options, { _id: self._id, _rev: self._rev, @@ -48,7 +48,7 @@ const mergeContacts = async (options, db) => { } trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, loserId); + const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, removedId); const ancestors = await Shared.fetch.ancestorsOf(db, contactDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); @@ -56,7 +56,7 @@ const mergeContacts = async (options, db) => { minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - const movedReportsCount = await moveReportsAndReassign(db, descendantsAndSelf, options, replacementLineage, loserId); + const movedReportsCount = await moveReportsAndReassign(db, descendantsAndSelf, options, replacementLineage, removedId); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -72,8 +72,8 @@ const mergeContacts = async (options, db) => { Checks for any errors which this will create in the hierarchy (hierarchy schema, circular hierarchies) Confirms the list of contacts are possible to move */ -const validateContacts = async (loserDocs, constraints) => { - Object.values(loserDocs).forEach(doc => { +const validateContacts = async (removedDocs, constraints) => { + Object.values(removedDocs).forEach(doc => { const hierarchyError = constraints.getMergeContactHierarchyViolations(doc); if (hierarchyError) { throw Error(`Hierarchy Constraints: ${hierarchyError}`); @@ -85,23 +85,23 @@ const validateContacts = async (loserDocs, constraints) => { const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const loserIds = (args.losers || args.loser || '') + const removedIds = (args.removed || '') .split(',') .filter(Boolean); - if (!args.winner) { + if (!args.kept) { usage(); - throw Error(`Action "merge-contacts" is missing required contact ID ${Shared.bold('--winner')}. Other contacts will be merged into this contact.`); + throw Error(`Action "merge-contacts" is missing required contact ID ${Shared.bold('--kept')}. Other contacts will be merged into this contact.`); } - if (loserIds.length === 0) { + if (removedIds.length === 0) { usage(); - throw Error(`Action "merge-contacts" is missing required contact ID(s) ${Shared.bold('--losers')}. These contacts will be merged into the contact specified by ${Shared.bold('--winner')}`); + throw Error(`Action "merge-contacts" is missing required contact ID(s) ${Shared.bold('--removed')}. These contacts will be merged into the contact specified by ${Shared.bold('--kept')}`); } return { - winnerId: args.winner, - loserIds, + keptId: args.kept, + removedIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; @@ -113,43 +113,43 @@ ${Shared.bold('cht-conf\'s merge-contacts action')} When combined with 'upload-docs' this action merges multiple contacts and all their associated data into one. ${Shared.bold('USAGE')} -cht --local merge-contacts -- --winner= --losers=, +cht --local merge-contacts -- --kept= --removed=, ${Shared.bold('OPTIONS')} ---winner= +--kept= Specifies the ID of the contact that should have all other contact data merged into it. ---losers=, - A comma delimited list of IDs of contacts which will be deleted and all of their data will be merged into the winner contact. +--removed=, + A comma delimited list of IDs of contacts which will be deleted and all of their data will be merged into the kept contact. --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); }; -const moveReportsAndReassign = async (db, descendantsAndSelf, writeOptions, replacementLineage, loserId) => { +const moveReportsAndReassign = async (db, descendantsAndSelf, writeOptions, replacementLineage, removedId) => { const descendantIds = descendantsAndSelf.map(contact => contact._id); - const winnerId = writeOptions.winnerId; + const keptId = writeOptions.keptId; let skip = 0; let reportDocsBatch; do { info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); - reportDocsBatch = await Shared.fetch.reportsCreatedByOrFor(db, descendantIds, loserId, skip); + reportDocsBatch = await Shared.fetch.reportsCreatedByOrFor(db, descendantIds, removedId, skip); - const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, loserId); + const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, removedId); reportDocsBatch.forEach(report => { let updated = false; const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; for (const subjectId of subjectIds) { - if (report[subjectId] === loserId) { - report[subjectId] = winnerId; + if (report[subjectId] === removedId) { + report[subjectId] = keptId; updated = true; } - if (report.fields[subjectId] === loserId) { - report.fields[subjectId] = winnerId; + if (report.fields[subjectId] === removedId) { + report.fields[subjectId] = keptId; updated = true; } diff --git a/src/lib/lineage-constraints.js b/src/lib/lineage-constraints.js index c3042ee2..9d64ed49 100644 --- a/src/lib/lineage-constraints.js +++ b/src/lib/lineage-constraints.js @@ -69,23 +69,23 @@ const getMoveContactHierarchyViolations = (mapTypeToAllowedParents, contactDoc, Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ -const getMergeContactHierarchyViolations = (loserDoc, winnerDoc) => { +const getMergeContactHierarchyViolations = (removedDoc, keptDoc) => { const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); - const loserContactType = getContactType(loserDoc); - const winnerContactType = getContactType(winnerDoc); - if (!loserContactType) { + const removedContactType = getContactType(removedDoc); + const keptContactType = getContactType(keptDoc); + if (!removedContactType) { return 'contact required attribute "type" is undefined'; } - if (winnerDoc && !winnerContactType) { - return `winner contact "${winnerDoc._id}" required attribute "type" is undefined`; + if (keptDoc && !keptContactType) { + return `kept contact "${keptDoc._id}" required attribute "type" is undefined`; } - if (loserContactType !== winnerContactType) { - return `contact "${loserDoc._id}" must have same contact type as "${winnerContactType}". Former is "${loserContactType}" while later is "${winnerContactType}".`; + if (removedContactType !== keptContactType) { + return `contact "${removedDoc._id}" must have same contact type as "${keptContactType}". Former is "${removedContactType}" while later is "${keptContactType}".`; } - if (loserDoc._id === winnerDoc._id) { + if (removedDoc._id === keptDoc._id) { return `Cannot merge contact with self`; } }; diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js index 3a61839b..2bf26504 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/mm-shared.js @@ -104,15 +104,15 @@ const fetch = { return reports.rows.map(row => row.doc); }, - reportsCreatedByOrFor: async (db, descendantIds, loserId, skip) => { + reportsCreatedByOrFor: async (db, descendantIds, removedId, skip) => { // TODO is this the right way? const reports = await db.query('medic-client/reports_by_freetext', { keys: [ ...descendantIds.map(descendantId => [`contact:${descendantId}`]), - [`patient_id:${loserId}`], - [`patient_uuid:${loserId}`], - [`place_id:${loserId}`], - [`place_uuid:${loserId}`], + [`patient_id:${removedId}`], + [`patient_uuid:${removedId}`], + [`place_id:${removedId}`], + [`place_uuid:${removedId}`], ], include_docs: true, limit: BATCH_SIZE, diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index 3aa5a98e..db427883 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -42,12 +42,19 @@ describe('merge-contacts', () => { delete result._rev; return result; }; + const expectWrittenDocs = expected => expect(writtenDocs.map(doc => doc._id)).to.have.members(expected); beforeEach(async () => { pouchDb = new PouchDB(`merge-contacts-${scenarioCount++}`); await mockHierarchy(pouchDb, { - district_1: {}, + district_1: { + health_center_1: { + clinic_1: { + patient_1: {}, + }, + } + }, district_2: { health_center_2: { clinic_2: { @@ -97,11 +104,19 @@ describe('merge-contacts', () => { // action await mergeContacts({ - loserIds: ['district_2'], - winnerId: 'district_1', + removedIds: ['district_2'], + keptId: 'district_1', }, pouchDb); // assert + expectWrittenDocs([ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'changing_subject_and_contact', 'changing_contact', 'changing_subject' + ]); + expect(getWrittenDoc('district_2')).to.deep.eq({ _id: 'district_2', _deleted: true, @@ -160,26 +175,67 @@ describe('merge-contacts', () => { }); }); - it('throw if loser does not exist', async () => { + it('merge two patients', async () => { + // setup + await mockReport(pouchDb, { + id: 'pat1', + creatorId: 'clinic_1_contact', + patientId: 'patient_1' + }); + + await mockReport(pouchDb, { + id: 'pat2', + creatorId: 'clinic_2_contact', + patientId: 'patient_2' + }); + + // action + await mergeContacts({ + removedIds: ['patient_2'], + keptId: 'patient_1', + }, pouchDb); + + await expectWrittenDocs(['patient_2', 'pat2']); + + expect(getWrittenDoc('patient_2')).to.deep.eq({ + _id: 'patient_2', + _deleted: true, + }); + + expect(getWrittenDoc('pat2')).to.deep.eq({ + _id: 'pat2', + form: 'foo', + type: 'data_record', + // still created by the user in district-2 + contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_2'), + fields: { + patient_uuid: 'patient_1' + } + }); + }); + + xit('write to ancestors', () => {}); + + it('throw if removed does not exist', async () => { const actual = mergeContacts({ - loserIds: ['dne'], - winnerId: 'district_1', + removedIds: ['dne'], + keptId: 'district_1', }, pouchDb); await expect(actual).to.eventually.rejectedWith('could not be found'); }); - it('throw if winner does not exist', async () => { + it('throw if kept does not exist', async () => { const actual = mergeContacts({ - loserIds: ['district_1'], - winnerId: 'dne', + removedIds: ['district_1'], + keptId: 'dne', }, pouchDb); await expect(actual).to.eventually.rejectedWith('could not be found'); }); - it('throw if loser is winner', async () => { + it('throw if removed is kept', async () => { const actual = mergeContacts({ - loserIds: ['district_1', 'district_2'], - winnerId: 'district_2', + removedIds: ['district_1', 'district_2'], + keptId: 'district_2', }, pouchDb); await expect(actual).to.eventually.rejectedWith('merge contact with self'); });