From d16df6d73fe1e80a97c820cbe9af80bdecd2ccb4 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 17:40:46 -0700 Subject: [PATCH 01/66] Testing this common library going to get weird --- .gitignore | 1 + src/fn/merge-contacts.js | 200 +++++++++++++++++++++++++++++++++++++++ src/fn/move-contacts.js | 129 +++---------------------- src/lib/mm-shared.js | 153 ++++++++++++++++++++++++++++++ 4 files changed, 365 insertions(+), 118 deletions(-) create mode 100644 src/fn/merge-contacts.js create mode 100644 src/lib/mm-shared.js diff --git a/.gitignore b/.gitignore index 39c909fa0..e1a85a64d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ upload-docs.*.log.json /.vscode/ /.idea/ /.settings/ +/json_docs/ *.swp coverage .nyc_output diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js new file mode 100644 index 000000000..963ae6aab --- /dev/null +++ b/src/fn/merge-contacts.js @@ -0,0 +1,200 @@ +const minimist = require('minimist'); +const path = require('path'); + +const environment = require('../lib/environment'); +const lineageManipulation = require('../lib/lineage-manipulation'); +const lineageConstraints = require('../lib/lineage-constraints'); +const pouch = require('../lib/db'); +const { trace, info } = require('../lib/log'); + +const { + BATCH_SIZE, + prepareDocumentDirectory, + prettyPrintDocument, + replaceLineageInAncestors, + bold, + writeDocumentToDisk, + fetch, +} = require('../lib/mm-shared'); + +module.exports = { + requiresInstance: true, + execute: () => { + const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); + const db = pouch(); + prepareDocumentDirectory(args); + return updateLineagesAndStage(args, db); + } +}; + +const updateLineagesAndStage = async (options, db) => { + trace(`Fetching contact details: ${options.winnerId}`); + const winnerDoc = await fetch.contact(db, options.winnerId); + + const constraints = await lineageConstraints(db, winnerDoc); + const loserDocs = await fetch.contactList(db, options.loserIds); + await validateContacts(loserDocs, constraints); + + let affectedContactCount = 0, affectedReportCount = 0; + const replacementLineage = lineageManipulation.createLineageFromDoc(winnerDoc); + for (let loserId of options.loserIds) { + const contactDoc = loserDocs[loserId]; + const descendantsAndSelf = await fetch.descendantsOf(db, loserId); + + const self = descendantsAndSelf.find(d => d._id === loserId); + writeDocumentToDisk(options, { + _id: self._id, + _rev: self._rev, + _deleted: true, + }); + + // Check that primary contact is not removed from areas where they are required + const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); + if (invalidPrimaryContactDoc) { + throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + } + + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); + const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, loserId); + + const ancestors = await fetch.ancestorsOf(db, contactDoc); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); + const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); + + minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); + + const movedReportsCount = await moveReports(db, descendantsAndSelf, options, options.winnerId, loserId); + trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); + + affectedContactCount += updatedDescendants.length + updatedAncestors.length; + affectedReportCount += movedReportsCount; + + info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + } + + info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); +}; + +/* +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 (contactDocs, constraints) => { + Object.values(contactDocs).forEach(doc => { + const hierarchyError = constraints.getHierarchyErrors(doc); + if (hierarchyError) { + throw Error(`Hierarchy Constraints: ${hierarchyError}`); + } + }); + + /* + It is nice that the tool can move lists of contacts as one operation, but strange things happen when two loserIds are in the same lineage. + For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. + */ + const loserIds = Object.keys(contactDocs); + Object.values(contactDocs) + .forEach(doc => { + const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; + const violatingParentId = parentIdsOfDoc.find(winnerId => loserIds.includes(winnerId)); + if (violatingParentId) { + throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); + } + }); +}; + +// Parses extraArgs and asserts if required parameters are not present +const parseExtraArgs = (projectDir, extraArgs = []) => { + const args = minimist(extraArgs, { boolean: true }); + + const loserIds = (args.losers || args.loser || '') + .split(',') + .filter(Boolean); + + if (loserIds.length === 0) { + usage(); + throw Error(`Action "merge-contacts" is missing required list of contacts ${bold('--losers')} to be merged into the winner`); + } + + if (!args.winner) { + usage(); + throw Error(`Action "merge-contacts" is missing required parameter ${bold('--winner')}`); + } + + return { + winnerId: args.winner, + loserIds, + docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), + force: !!args.force, + }; +}; + +const usage = () => { + info(` +${bold('cht-conf\'s merge-contacts action')} +When combined with 'upload-docs' this action merges multiple contacts and all their associated data into one. + +${bold('USAGE')} +cht --local merge-contacts -- --winner= --losers=, + +${bold('OPTIONS')} +--winner= + 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. + +--docDirectoryPath= + Specifies the folder used to store the documents representing the changes in hierarchy. +`); +}; + +const moveReports = async (db, descendantsAndSelf, writeOptions, winnerId, loserId) => { + let skip = 0; + let reportDocsBatch; + do { + info(`Processing ${skip} to ${skip + BATCH_SIZE} report docs`); + reportDocsBatch = await fetch.reportsCreatedFor(db, loserId, skip); + + reportDocsBatch.forEach(report => { + const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + for (const subjectId of subjectIds) { + if (report[subjectId]) { + report[subjectId] = winnerId; + } + + if (report.fields[subjectId]) { + report.fields[subjectId] = winnerId; + } + } + + writeDocumentToDisk(writeOptions, report); + }); + + skip += reportDocsBatch.length; + } while (reportDocsBatch.length >= BATCH_SIZE); + + return skip; +}; + +const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { + docs.forEach(doc => { + lineageManipulation.minifyLineagesInDoc(doc); + writeDocumentToDisk(parsedArgs, doc); + }); +}; + +const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => { + // skip top-level because it is now being deleted + if (doc._id === contactId) { + return agg; + } + + const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, contactId); + + // TODO: seems wrong + const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, contactId); + if (parentWasUpdated || contactWasUpdated) { + agg.push(doc); + } + return agg; +}, []); diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 6b29e3b03..e0c9ff24f 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -1,16 +1,21 @@ const minimist = require('minimist'); const path = require('path'); -const userPrompt = require('../lib/user-prompt'); const environment = require('../lib/environment'); -const fs = require('../lib/sync-fs'); const lineageManipulation = require('../lib/lineage-manipulation'); const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); -const { warn, trace, info } = require('../lib/log'); - -const HIERARCHY_ROOT = 'root'; -const BATCH_SIZE = 10000; +const { trace, info } = require('../lib/log'); + +const { + HIERARCHY_ROOT, + BATCH_SIZE, + prepareDocumentDirectory, + prettyPrintDocument, + replaceLineageInAncestors, + bold, + fetch, +} = require('../lib/mm-shared'); module.exports = { requiresInstance: true, @@ -22,7 +27,6 @@ module.exports = { } }; -const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; const updateLineagesAndStage = async (options, db) => { trace(`Fetching contact details for parent: ${options.parentId}`); const parentDoc = await fetch.contact(db, options.parentId); @@ -117,21 +121,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { }; }; -const prepareDocumentDirectory = ({ docDirectoryPath, force }) => { - if (!fs.exists(docDirectoryPath)) { - fs.mkdir(docDirectoryPath); - } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { - warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); - if(userPrompt.keyInYN()) { - fs.deleteFilesInFolder(docDirectoryPath); - } else { - throw new Error('User aborted execution.'); - } - } -}; - const usage = () => { - const bold = text => `\x1b[1m${text}\x1b[0m`; info(` ${bold('cht-conf\'s move-contacts action')} When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another. @@ -176,92 +166,6 @@ const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { }); }; -const writeDocumentToDisk = ({ docDirectoryPath }, doc) => { - const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); - if (fs.exists(destinationPath)) { - warn(`File at ${destinationPath} already exists and is being overwritten.`); - } - - trace(`Writing updated document to ${destinationPath}`); - fs.writeJson(destinationPath, doc); -}; - -const fetch = { - /* - Fetches all of the documents associated with the "contactIds" and confirms they exist. - */ - contactList: async (db, ids) => { - const contactDocs = await db.allDocs({ - keys: ids, - include_docs: true, - }); - - const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); - if (missingContactErrors.length > 0) { - throw Error(missingContactErrors); - } - - return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); - }, - - contact: async (db, id) => { - try { - if (id === HIERARCHY_ROOT) { - return undefined; - } - - return await db.get(id); - } catch (err) { - if (err.name !== 'not_found') { - throw err; - } - - throw Error(`Contact with id '${id}' could not be found`); - } - }, - - /* - Given a contact's id, obtain the documents of all descendant contacts - */ - descendantsOf: async (db, contactId) => { - const descendantDocs = await db.query('medic/contacts_by_depth', { - key: [contactId], - include_docs: true, - }); - - return descendantDocs.rows - .map(row => row.doc) - /* We should not move or update tombstone documents */ - .filter(doc => doc && doc.type !== 'tombstone'); - }, - - reportsCreatedBy: async (db, contactIds, skip) => { - const reports = await db.query('medic-client/reports_by_freetext', { - keys: contactIds.map(id => [`contact:${id}`]), - include_docs: true, - limit: BATCH_SIZE, - skip: skip, - }); - - return reports.rows.map(row => row.doc); - }, - - ancestorsOf: async (db, contactDoc) => { - const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); - const ancestors = await db.allDocs({ - keys: ancestorIds, - include_docs: true, - }); - - const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); - if (ancestorIdsNotFound.length > 0) { - throw Error(`Contact '${prettyPrintDocument(contactDoc)} has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); - } - - return ancestors.rows.map(ancestor => ancestor.doc); - }, -}; - const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) { agg.push(doc); @@ -278,14 +182,3 @@ const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contac } return agg; }, []); - -const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); - if (primaryContact) { - ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; - } - - return result; -}, []); diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js new file mode 100644 index 000000000..bd324a13f --- /dev/null +++ b/src/lib/mm-shared.js @@ -0,0 +1,153 @@ +const path = require('path'); + +const userPrompt = require('./user-prompt'); +const fs = require('./sync-fs'); +const { warn, trace } = require('./log'); +const lineageManipulation = require('./lineage-manipulation'); + +const HIERARCHY_ROOT = 'root'; +const BATCH_SIZE = 10000; + +const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; + +const prepareDocumentDirectory = ({ docDirectoryPath, force }) => { + if (!fs.exists(docDirectoryPath)) { + fs.mkdir(docDirectoryPath); + } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { + warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); + if(userPrompt.keyInYN()) { + fs.deleteFilesInFolder(docDirectoryPath); + } else { + throw new Error('User aborted execution.'); + } + } +}; + +const writeDocumentToDisk = ({ docDirectoryPath }, doc) => { + const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); + if (fs.exists(destinationPath)) { + warn(`File at ${destinationPath} already exists and is being overwritten.`); + } + + trace(`Writing updated document to ${destinationPath}`); + fs.writeJson(destinationPath, doc); +}; + +const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { + let result = agg; + const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + if (primaryContact) { + ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); + result = [ancestor, ...result]; + } + + return result; +}, []); + + +const fetch = { + /* + Fetches all of the documents associated with the "contactIds" and confirms they exist. + */ + contactList: async (db, ids) => { + const contactDocs = await db.allDocs({ + keys: ids, + include_docs: true, + }); + + const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); + if (missingContactErrors.length > 0) { + throw Error(missingContactErrors); + } + + return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); + }, + + contact: async (db, id) => { + try { + if (id === HIERARCHY_ROOT) { + return undefined; + } + + return await db.get(id); + } catch (err) { + if (err.name !== 'not_found') { + throw err; + } + + throw Error(`Contact with id '${id}' could not be found`); + } + }, + + /* + Given a contact's id, obtain the documents of all descendant contacts + */ + descendantsOf: async (db, contactId) => { + const descendantDocs = await db.query('medic/contacts_by_depth', { + key: [contactId], + include_docs: true, + }); + + return descendantDocs.rows + .map(row => row.doc) + /* We should not move or update tombstone documents */ + .filter(doc => doc && doc.type !== 'tombstone'); + }, + + reportsCreatedBy: async (db, contactIds, skip) => { + const reports = await db.query('medic-client/reports_by_freetext', { + keys: contactIds.map(id => [`contact:${id}`]), + include_docs: true, + limit: BATCH_SIZE, + skip, + }); + + return reports.rows.map(row => row.doc); + }, + + reportsCreatedFor: async (db, contactId, skip) => { + // TODO is this the right way? + const reports = await db.query('medic-client/reports_by_freetext', { + keys: [ + [`patient_id:${contactId}`], + [`patient_uuid:${contactId}`], + [`place_id:${contactId}`], + [`place_uuid:${contactId}`], + ], + include_docs: true, + limit: BATCH_SIZE, + skip, + }); + + return reports.rows.map(row => row.doc); + }, + + ancestorsOf: async (db, contactDoc) => { + const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); + const ancestors = await db.allDocs({ + keys: ancestorIds, + include_docs: true, + }); + + const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); + if (ancestorIdsNotFound.length > 0) { + throw Error(`Contact '${prettyPrintDocument(contactDoc)} has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); + } + + return ancestors.rows.map(ancestor => ancestor.doc); + }, +}; + +const bold = text => `\x1b[1m${text}\x1b[0m`; + +module.exports = { + HIERARCHY_ROOT, + BATCH_SIZE, + bold, + prepareDocumentDirectory, + prettyPrintDocument, + minifyLineageAndWriteToDisk, + replaceLineageInAncestors, + writeDocumentToDisk, + fetch, +}; From 3e1827ede9c5838f8bae86958d21d8904e57aaf8 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 6 Nov 2024 22:28:19 -0700 Subject: [PATCH 02/66] Move-Contacts tests passing again --- src/fn/move-contacts.js | 46 +++++++++++-------------- src/lib/mm-shared.js | 1 - test/fn/mm-shared.spec.js | 50 +++++++++++++++++++++++++++ test/fn/move-contacts.spec.js | 63 ++++++++--------------------------- 4 files changed, 82 insertions(+), 78 deletions(-) create mode 100644 test/fn/mm-shared.spec.js diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index e0c9ff24f..342ef944b 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -7,52 +7,44 @@ const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); const { trace, info } = require('../lib/log'); -const { - HIERARCHY_ROOT, - BATCH_SIZE, - prepareDocumentDirectory, - prettyPrintDocument, - replaceLineageInAncestors, - bold, - fetch, -} = require('../lib/mm-shared'); +const Shared = require('../lib/mm-shared'); module.exports = { requiresInstance: true, execute: () => { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); - prepareDocumentDirectory(args); + Shared.prepareDocumentDirectory(args); return updateLineagesAndStage(args, db); } }; const updateLineagesAndStage = async (options, db) => { trace(`Fetching contact details for parent: ${options.parentId}`); - const parentDoc = await fetch.contact(db, options.parentId); + const parentDoc = await Shared.fetch.contact(db, options.parentId); const constraints = await lineageConstraints(db, parentDoc); - const contactDocs = await fetch.contactList(db, options.contactIds); + const contactDocs = await Shared.fetch.contactList(db, options.contactIds); await validateContacts(contactDocs, constraints); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(parentDoc); for (let contactId of options.contactIds) { const contactDoc = contactDocs[contactId]; - const descendantsAndSelf = await fetch.descendantsOf(db, contactId); + const descendantsAndSelf = await Shared.fetch.descendantsOf(db, contactId); // Check that primary contact is not removed from areas where they are required const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + throw Error(`Cannot remove contact ${Shared.prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); } - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${Shared.prettyPrintDocument(contactDoc)}.`); const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, contactId); - const ancestors = await fetch.ancestorsOf(db, contactDoc); - trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); + const ancestors = await Shared.fetch.ancestorsOf(db, contactDoc); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${Shared.prettyPrintDocument(contactDoc)}.`); + const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); @@ -62,7 +54,7 @@ const updateLineagesAndStage = async (options, db) => { affectedContactCount += updatedDescendants.length + updatedAncestors.length; affectedReportCount += movedReportsCount; - info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + info(`Staged updates to ${Shared.prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); } info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); @@ -123,18 +115,18 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { const usage = () => { info(` -${bold('cht-conf\'s move-contacts action')} +${Shared.bold('cht-conf\'s move-contacts action')} When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another. -${bold('USAGE')} +${Shared.bold('USAGE')} cht --local move-contacts -- --contacts=, --parent= -${bold('OPTIONS')} +${Shared.bold('OPTIONS')} --contacts=, A comma delimited list of ids of contacts to be moved. --parent= - Specifies the ID of the new parent. Use '${HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). + Specifies the ID of the new parent. Use '${Shared.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. @@ -147,14 +139,14 @@ const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLine let skip = 0; let reportDocsBatch; do { - info(`Processing ${skip} to ${skip + BATCH_SIZE} report docs`); - reportDocsBatch = await fetch.reportsCreatedBy(db, contactIds, skip); + info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); + reportDocsBatch = await Shared.fetch.reportsCreatedBy(db, contactIds, skip); const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId); minifyLineageAndWriteToDisk(updatedReports, writeOptions); skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= BATCH_SIZE); + } while (reportDocsBatch.length >= Shared.BATCH_SIZE); return skip; }; @@ -162,7 +154,7 @@ const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLine const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { docs.forEach(doc => { lineageManipulation.minifyLineagesInDoc(doc); - writeDocumentToDisk(parsedArgs, doc); + Shared.writeDocumentToDisk(parsedArgs, doc); }); }; diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js index bd324a13f..a9b8c56e3 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/mm-shared.js @@ -146,7 +146,6 @@ module.exports = { bold, prepareDocumentDirectory, prettyPrintDocument, - minifyLineageAndWriteToDisk, replaceLineageInAncestors, writeDocumentToDisk, fetch, diff --git a/test/fn/mm-shared.spec.js b/test/fn/mm-shared.spec.js new file mode 100644 index 000000000..8902613cd --- /dev/null +++ b/test/fn/mm-shared.spec.js @@ -0,0 +1,50 @@ +const { assert } = require('chai'); +const rewire = require('rewire'); +const sinon = require('sinon'); + +const environment = require('../../src/lib/environment'); +const fs = require('../../src/lib/sync-fs'); +const Shared = rewire('../../src/lib/mm-shared'); +const userPrompt = rewire('../../src/lib/user-prompt'); + + +describe('mm-shared', () => { + let readline; + + let docOnj = { docDirectoryPath: '/test/path/for/testing ', force: false }; + beforeEach(() => { + readline = { keyInYN: sinon.stub() }; + userPrompt.__set__('readline', readline); + Shared.__set__('userPrompt', userPrompt); + sinon.stub(fs, 'exists').returns(true); + sinon.stub(fs, 'recurseFiles').returns(Array(20)); + sinon.stub(fs, 'deleteFilesInFolder').returns(true); + }); + afterEach(() => { + sinon.restore(); + }); + + it('does not delete files in directory when user presses n', () => { + readline.keyInYN.returns(false); + sinon.stub(environment, 'force').get(() => false); + try { + Shared.prepareDocumentDirectory(docOnj); + assert.fail('Expected error to be thrown'); + } catch(e) { + assert.equal(fs.deleteFilesInFolder.callCount, 0); + } + }); + + it('deletes files in directory when user presses y', () => { + readline.keyInYN.returns(true); + sinon.stub(environment, 'force').get(() => false); + Shared.prepareDocumentDirectory(docOnj); + assert.equal(fs.deleteFilesInFolder.callCount, 1); + }); + + it('deletes files in directory when force is set', () => { + sinon.stub(environment, 'force').get(() => true); + Shared.prepareDocumentDirectory(docOnj); + assert.equal(fs.deleteFilesInFolder.callCount, 1); + }); +}); diff --git a/test/fn/move-contacts.spec.js b/test/fn/move-contacts.spec.js index a7f471282..1d27c6a3b 100644 --- a/test/fn/move-contacts.spec.js +++ b/test/fn/move-contacts.spec.js @@ -4,12 +4,15 @@ const sinon = require('sinon'); const fs = require('../../src/lib/sync-fs'); const environment = require('../../src/lib/environment'); +const Shared = rewire('../../src/lib/mm-shared'); + const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); const moveContactsModule = rewire('../../src/fn/move-contacts'); -moveContactsModule.__set__('prepareDocumentDirectory', () => {}); +moveContactsModule.__set__('Shared', Shared); + const updateLineagesAndStage = moveContactsModule.__get__('updateLineagesAndStage'); const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); @@ -80,7 +83,8 @@ describe('move-contacts', () => { views: { contacts_by_depth }, }); - moveContactsModule.__set__('writeDocumentToDisk', (docDirectoryPath, doc) => writtenDocs.push(doc)); + Shared.writeDocumentToDisk = (docDirectoryPath, doc) => writtenDocs.push(doc); + Shared.prepareDocumentDirectory = () => {}; writtenDocs.length = 0; }); @@ -549,53 +553,9 @@ describe('move-contacts', () => { }); }); - let readline; - describe('prepareDocumentDirectory', () => { - const moveContacts = rewire('../../src/fn/move-contacts'); - const userPrompt = rewire('../../src/lib/user-prompt'); - const prepareDocDir = moveContacts.__get__('prepareDocumentDirectory'); - let docOnj = { docDirectoryPath: '/test/path/for/testing ', force: false }; - beforeEach(() => { - readline = { keyInYN: sinon.stub() }; - userPrompt.__set__('readline', readline); - moveContacts.__set__('userPrompt', userPrompt); - sinon.stub(fs, 'exists').returns(true); - sinon.stub(fs, 'recurseFiles').returns(Array(20)); - sinon.stub(fs, 'deleteFilesInFolder').returns(true); - }); - afterEach(() => { - sinon.restore(); - }); - - it('does not delete files in directory when user presses n', () => { - readline.keyInYN.returns(false); - sinon.stub(environment, 'force').get(() => false); - try { - prepareDocDir(docOnj); - assert.fail('Expected error to be thrown'); - } catch(e) { - assert.equal(fs.deleteFilesInFolder.callCount, 0); - } - }); - - it('deletes files in directory when user presses y', () => { - readline.keyInYN.returns(true); - sinon.stub(environment, 'force').get(() => false); - prepareDocDir(docOnj); - assert.equal(fs.deleteFilesInFolder.callCount, 1); - }); - - it('deletes files in directory when force is set', () => { - sinon.stub(environment, 'force').get(() => true); - prepareDocDir(docOnj); - assert.equal(fs.deleteFilesInFolder.callCount, 1); - }); - }); - describe('batching works as expected', () => { - let defaultBatchSize; + const initialBatchSize = Shared.BATCH_SIZE; beforeEach(async () => { - defaultBatchSize = moveContactsModule.__get__('BATCH_SIZE'); await mockReport(pouchDb, { id: 'report_2', creatorId: 'health_center_1_contact', @@ -613,11 +573,13 @@ describe('move-contacts', () => { }); afterEach(() => { - moveContactsModule.__set__('BATCH_SIZE', defaultBatchSize); + Shared.BATCH_SIZE = initialBatchSize; + Shared.__set__('BATCH_SIZE', initialBatchSize); }); it('move health_center_1 to district_2 in batches of 1', async () => { - moveContactsModule.__set__('BATCH_SIZE', 1); + Shared.__set__('BATCH_SIZE', 1); + Shared.BATCH_SIZE = 1; sinon.spy(pouchDb, 'query'); await updateLineagesAndStage({ @@ -692,7 +654,8 @@ describe('move-contacts', () => { }); it('should health_center_1 to district_1 in batches of 2', async () => { - moveContactsModule.__set__('BATCH_SIZE', 2); + Shared.__set__('BATCH_SIZE', 2); + Shared.BATCH_SIZE = 2; sinon.spy(pouchDb, 'query'); await updateLineagesAndStage({ From d914e64fd436955edc6a2f367773cd456f245835 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 12 Nov 2024 16:09:25 -0700 Subject: [PATCH 03/66] First test passing for merge --- package-lock.json | 1 + package.json | 1 + src/fn/merge-contacts.js | 108 +++++++++--------- src/fn/move-contacts.js | 12 +- src/lib/lineage-constraints.js | 31 ++++- src/lib/lineage-manipulation.js | 59 ++++++---- src/lib/mm-shared.js | 14 ++- test/fn/merge-contacts.spec.js | 158 ++++++++++++++++++++++++++ test/lib/lineage-constraints.spec.js | 36 +++--- test/lib/lineage-manipulation.spec.js | 18 +-- test/mock-hierarchies.js | 7 +- 11 files changed, 329 insertions(+), 116 deletions(-) create mode 100644 test/fn/merge-contacts.spec.js diff --git a/package-lock.json b/package-lock.json index 60ff114c6..09d2cfac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/package.json b/package.json index c06ae5d57..1f5a35ba2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 963ae6aab..0284f68c5 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -7,47 +7,40 @@ const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); const { trace, info } = require('../lib/log'); -const { - BATCH_SIZE, - prepareDocumentDirectory, - prettyPrintDocument, - replaceLineageInAncestors, - bold, - writeDocumentToDisk, - fetch, -} = require('../lib/mm-shared'); +const Shared = require('../lib/mm-shared'); module.exports = { requiresInstance: true, execute: () => { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); - prepareDocumentDirectory(args); - return updateLineagesAndStage(args, db); + Shared.prepareDocumentDirectory(args); + return mergeContacts(args, db); } }; -const updateLineagesAndStage = async (options, db) => { +const mergeContacts = async (options, db) => { trace(`Fetching contact details: ${options.winnerId}`); - const winnerDoc = await fetch.contact(db, options.winnerId); + const winnerDoc = await Shared.fetch.contact(db, options.winnerId); const constraints = await lineageConstraints(db, winnerDoc); - const loserDocs = await fetch.contactList(db, options.loserIds); + const loserDocs = await Shared.fetch.contactList(db, options.loserIds); await validateContacts(loserDocs, constraints); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(winnerDoc); for (let loserId of options.loserIds) { const contactDoc = loserDocs[loserId]; - const descendantsAndSelf = await fetch.descendantsOf(db, loserId); + const descendantsAndSelf = await Shared.fetch.descendantsOf(db, loserId); const self = descendantsAndSelf.find(d => d._id === loserId); - writeDocumentToDisk(options, { + Shared.writeDocumentToDisk(options, { _id: self._id, _rev: self._rev, _deleted: true, }); + const { prettyPrintDocument } = Shared; // Check that primary contact is not removed from areas where they are required const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); if (invalidPrimaryContactDoc) { @@ -57,13 +50,13 @@ const updateLineagesAndStage = async (options, db) => { trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, loserId); - const ancestors = await fetch.ancestorsOf(db, contactDoc); + const ancestors = await Shared.fetch.ancestorsOf(db, contactDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); + const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - const movedReportsCount = await moveReports(db, descendantsAndSelf, options, options.winnerId, loserId); + const movedReportsCount = await reassignReportSubjects(db, descendantsAndSelf, options, replacementLineage, loserId); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -79,27 +72,13 @@ const updateLineagesAndStage = 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 (contactDocs, constraints) => { - Object.values(contactDocs).forEach(doc => { - const hierarchyError = constraints.getHierarchyErrors(doc); +const validateContacts = async (loserDocs, constraints) => { + Object.values(loserDocs).forEach(doc => { + const hierarchyError = constraints.getMergeContactHierarchyViolations(doc); if (hierarchyError) { throw Error(`Hierarchy Constraints: ${hierarchyError}`); } }); - - /* - It is nice that the tool can move lists of contacts as one operation, but strange things happen when two loserIds are in the same lineage. - For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. - */ - const loserIds = Object.keys(contactDocs); - Object.values(contactDocs) - .forEach(doc => { - const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; - const violatingParentId = parentIdsOfDoc.find(winnerId => loserIds.includes(winnerId)); - if (violatingParentId) { - throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); - } - }); }; // Parses extraArgs and asserts if required parameters are not present @@ -112,12 +91,12 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { if (loserIds.length === 0) { usage(); - throw Error(`Action "merge-contacts" is missing required list of contacts ${bold('--losers')} to be merged into the winner`); + throw Error(`Action "merge-contacts" is missing required list of contacts ${Shared.bold('--losers')} to be merged into the winner`); } if (!args.winner) { usage(); - throw Error(`Action "merge-contacts" is missing required parameter ${bold('--winner')}`); + throw Error(`Action "merge-contacts" is missing required parameter ${Shared.bold('--winner')}`); } return { @@ -130,13 +109,13 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { const usage = () => { info(` -${bold('cht-conf\'s merge-contacts action')} +${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. -${bold('USAGE')} +${Shared.bold('USAGE')} cht --local merge-contacts -- --winner= --losers=, -${bold('OPTIONS')} +${Shared.bold('OPTIONS')} --winner= Specifies the ID of the contact that should have all other contact data merged into it. @@ -148,38 +127,61 @@ ${bold('OPTIONS')} `); }; -const moveReports = async (db, descendantsAndSelf, writeOptions, winnerId, loserId) => { +const reassignReportSubjects = async (db, descendantsAndSelf, writeOptions, replacementLineage, loserId) => { + const descendantIds = descendantsAndSelf.map(contact => contact._id); + const winnerId = writeOptions.winnerId; + let skip = 0; let reportDocsBatch; do { - info(`Processing ${skip} to ${skip + BATCH_SIZE} report docs`); - reportDocsBatch = await fetch.reportsCreatedFor(db, loserId, skip); + info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); + reportDocsBatch = await Shared.fetch.reportsCreatedByOrFor(db, descendantIds, loserId, skip); + + const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, loserId); reportDocsBatch.forEach(report => { + let updated = false; const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; for (const subjectId of subjectIds) { - if (report[subjectId]) { + if (report[subjectId] === loserId) { report[subjectId] = winnerId; + updated = true; } - if (report.fields[subjectId]) { + if (report.fields[subjectId] === loserId) { report.fields[subjectId] = winnerId; + updated = true; } - } - writeDocumentToDisk(writeOptions, report); + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); + } + } + } }); + minifyLineageAndWriteToDisk(updatedReports, writeOptions); + skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= BATCH_SIZE); + } while (reportDocsBatch.length >= Shared.BATCH_SIZE); return skip; }; +// Shared? +const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { + if (lineageManipulation.replaceLineageAt(doc, 'contact', replaceWith, startingFromIdInLineage)) { + agg.push(doc); + } + return agg; +}, []); + const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { docs.forEach(doc => { lineageManipulation.minifyLineagesInDoc(doc); - writeDocumentToDisk(parsedArgs, doc); + Shared.writeDocumentToDisk(parsedArgs, doc); }); }; @@ -189,10 +191,8 @@ const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contac return agg; } - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, contactId); - - // TODO: seems wrong - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, contactId); + const parentWasUpdated = lineageManipulation.replaceLineageAt(doc, 'parent', replacementLineage, contactId); + const contactWasUpdated = lineageManipulation.replaceLineageAt(doc, 'contact', replacementLineage, contactId); if (parentWasUpdated || contactWasUpdated) { agg.push(doc); } diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 342ef944b..66c160863 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -66,7 +66,7 @@ Confirms the list of contacts are possible to move */ const validateContacts = async (contactDocs, constraints) => { Object.values(contactDocs).forEach(doc => { - const hierarchyError = constraints.getHierarchyErrors(doc); + const hierarchyError = constraints.getMoveContactHierarchyViolations(doc); if (hierarchyError) { throw Error(`Hierarchy Constraints: ${hierarchyError}`); } @@ -142,8 +142,8 @@ const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLine info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); reportDocsBatch = await Shared.fetch.reportsCreatedBy(db, contactIds, skip); - const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId); - minifyLineageAndWriteToDisk(updatedReports, writeOptions); + const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId); + minifyLineageAndWriteToDisk(updatedReports, writeOptions); skip += reportDocsBatch.length; } while (reportDocsBatch.length >= Shared.BATCH_SIZE); @@ -159,7 +159,7 @@ const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { }; const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) { + if (lineageManipulation.replaceLineageAfter(doc, 'contact', replaceWith, startingFromIdInLineage)) { agg.push(doc); } return agg; @@ -167,8 +167,8 @@ const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, start const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => { const startingFromIdInLineage = doc._id === contactId ? undefined : contactId; - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage); - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, contactId); + const parentWasUpdated = lineageManipulation.replaceLineageAfter(doc, 'parent', replacementLineage, startingFromIdInLineage); + const contactWasUpdated = lineageManipulation.replaceLineageAfter(doc, 'contact', replacementLineage, contactId); if (parentWasUpdated || contactWasUpdated) { agg.push(doc); } diff --git a/src/lib/lineage-constraints.js b/src/lib/lineage-constraints.js index c0eb59647..c3042ee29 100644 --- a/src/lib/lineage-constraints.js +++ b/src/lib/lineage-constraints.js @@ -32,8 +32,9 @@ const lineageConstraints = async (repository, parentDoc) => { } return { - getHierarchyErrors: contactDoc => getHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc), getPrimaryContactViolations: (contactDoc, descendantDocs) => getPrimaryContactViolations(repository, contactDoc, parentDoc, descendantDocs), + getMoveContactHierarchyViolations: contactDoc => getMoveContactHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc), + getMergeContactHierarchyViolations: contactDoc => getMergeContactHierarchyViolations(contactDoc, parentDoc), }; }; @@ -41,7 +42,8 @@ const lineageConstraints = async (repository, parentDoc) => { Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ -const getHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) => { +const getMoveContactHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) => { + // TODO reuse this code const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); const contactType = getContactType(contactDoc); const parentType = getContactType(parentDoc); @@ -63,6 +65,31 @@ const getHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) } }; +/* +Enforce the list of allowed parents for each contact type +Ensure we are not creating a circular hierarchy +*/ +const getMergeContactHierarchyViolations = (loserDoc, winnerDoc) => { + const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); + const loserContactType = getContactType(loserDoc); + const winnerContactType = getContactType(winnerDoc); + if (!loserContactType) { + return 'contact required attribute "type" is undefined'; + } + + if (winnerDoc && !winnerContactType) { + return `winner contact "${winnerDoc._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 (loserDoc._id === winnerDoc._id) { + return `Cannot merge contact with self`; + } +}; + /* A place's primary contact must be a descendant of that place. diff --git a/src/lib/lineage-manipulation.js b/src/lib/lineage-manipulation.js index e87eb7107..001c637dc 100644 --- a/src/lib/lineage-manipulation.js +++ b/src/lib/lineage-manipulation.js @@ -5,32 +5,17 @@ Given a doc, replace the lineage information therein with "replaceWith" startingFromIdInLineage (optional) - Will result in a partial replacement of the lineage. Only the part of the lineage "after" the parent with _id=startingFromIdInLineage will be replaced by "replaceWith" */ -const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { - const handleReplacement = (replaceInDoc, docAttr, replaceWith) => { - if (!replaceWith) { - const lineageWasDeleted = !!replaceInDoc[docAttr]; - replaceInDoc[docAttr] = undefined; - return lineageWasDeleted; - } else if (replaceInDoc[docAttr]) { - replaceInDoc[docAttr]._id = replaceWith._id; - replaceInDoc[docAttr].parent = replaceWith.parent; - } else { - replaceInDoc[docAttr] = replaceWith; - } - - return true; - }; - +const replaceLineageAfter = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { // Replace the full lineage if (!startingFromIdInLineage) { - return handleReplacement(doc, lineageAttributeName, replaceWith); + return _doReplaceInLineage(doc, lineageAttributeName, replaceWith); } // Replace part of a lineage let currentParent = doc[lineageAttributeName]; while (currentParent) { if (currentParent._id === startingFromIdInLineage) { - return handleReplacement(currentParent, 'parent', replaceWith); + return _doReplaceInLineage(currentParent, 'parent', replaceWith); } currentParent = currentParent.parent; } @@ -38,6 +23,41 @@ const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdIn return false; }; +const replaceLineageAt = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { + if (!replaceWith || !startingFromIdInLineage) { + throw Error('replaceWith and startingFromIdInLineage must be defined'); + } + + // Replace part of a lineage + let currentElement = doc; + let currentAttributeName = lineageAttributeName; + while (currentElement) { + if (currentElement[currentAttributeName]?._id === startingFromIdInLineage) { + return _doReplaceInLineage(currentElement, currentAttributeName, replaceWith); + } + + currentElement = currentElement[currentAttributeName]; + currentAttributeName = 'parent'; + } + + return false; +}; + +const _doReplaceInLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { + if (!replaceWith) { + const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; + replaceInDoc[lineageAttributeName] = undefined; + return lineageWasDeleted; + } else if (replaceInDoc[lineageAttributeName]) { + replaceInDoc[lineageAttributeName]._id = replaceWith._id; + replaceInDoc[lineageAttributeName].parent = replaceWith.parent; + } else { + replaceInDoc[lineageAttributeName] = replaceWith; + } + + return true; +}; + /* Function borrowed from shared-lib/lineage */ @@ -103,5 +123,6 @@ module.exports = { createLineageFromDoc, minifyLineagesInDoc, pluckIdsFromLineage, - replaceLineage, + replaceLineageAfter, + replaceLineageAt, }; diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js index a9b8c56e3..6783e2d8b 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/mm-shared.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const path = require('path'); const userPrompt = require('./user-prompt'); @@ -105,21 +106,22 @@ const fetch = { return reports.rows.map(row => row.doc); }, - reportsCreatedFor: async (db, contactId, skip) => { + reportsCreatedByOrFor: async (db, descendantIds, loserId, skip) => { // TODO is this the right way? const reports = await db.query('medic-client/reports_by_freetext', { keys: [ - [`patient_id:${contactId}`], - [`patient_uuid:${contactId}`], - [`place_id:${contactId}`], - [`place_uuid:${contactId}`], + ...descendantIds.map(descendantId => [`contact:${descendantId}`]), + [`patient_id:${loserId}`], + [`patient_uuid:${loserId}`], + [`place_id:${loserId}`], + [`place_uuid:${loserId}`], ], include_docs: true, limit: BATCH_SIZE, skip, }); - return reports.rows.map(row => row.doc); + return _.uniqBy(reports.rows.map(row => row.doc), '_id'); }, ancestorsOf: async (db, contactDoc) => { diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js new file mode 100644 index 000000000..93da88fbf --- /dev/null +++ b/test/fn/merge-contacts.spec.js @@ -0,0 +1,158 @@ +const { assert, expect } = require('chai'); +const rewire = require('rewire'); +const sinon = require('sinon'); + +const Shared = rewire('../../src/lib/mm-shared'); + +const PouchDB = require('pouchdb-core'); +PouchDB.plugin(require('pouchdb-adapter-memory')); +PouchDB.plugin(require('pouchdb-mapreduce')); + +const mergeContactsModule = rewire('../../src/fn/merge-contacts'); +mergeContactsModule.__set__('Shared', Shared); + +const mergeContacts = mergeContactsModule.__get__('mergeContacts'); +const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); + +const contacts_by_depth = { + // eslint-disable-next-line quotes + map: "function(doc) {\n if (doc.type === 'tombstone' && doc.tombstone) {\n doc = doc.tombstone;\n }\n if (['contact', 'person', 'clinic', 'health_center', 'district_hospital'].indexOf(doc.type) !== -1) {\n var value = doc.patient_id || doc.place_id;\n var parent = doc;\n var depth = 0;\n while (parent) {\n if (parent._id) {\n emit([parent._id], value);\n emit([parent._id, depth], value);\n }\n depth++;\n parent = parent.parent;\n }\n }\n}", +}; + +const reports_by_freetext = { + // eslint-disable-next-line quotes + map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" +}; + +describe('merge-contacts', () => { + let pouchDb, scenarioCount = 0; + const writtenDocs = []; + const getWrittenDoc = docId => { + const matches = writtenDocs.filter(doc => doc && doc._id === docId); + if (matches.length === 0) { + return undefined; + } + + // Remove _rev because it makes expectations harder to write + const result = matches[matches.length - 1]; + delete result._rev; + return result; + }; + + beforeEach(async () => { + pouchDb = new PouchDB(`merge-contacts-${scenarioCount++}`); + + await mockHierarchy(pouchDb, { + district_1: {}, + district_2: { + health_center_2: { + clinic_2: { + patient_2: {}, + }, + } + }, + }); + + await pouchDb.put({ _id: 'settings', settings: {} }); + + await pouchDb.put({ + _id: '_design/medic-client', + views: { reports_by_freetext }, + }); + + await pouchDb.put({ + _id: '_design/medic', + views: { contacts_by_depth }, + }); + + Shared.writeDocumentToDisk = (docDirectoryPath, doc) => writtenDocs.push(doc); + Shared.prepareDocumentDirectory = () => {}; + writtenDocs.length = 0; + }); + + afterEach(async () => pouchDb.destroy()); + + it('merge district_2 into district_1', async () => { + // setup + await mockReport(pouchDb, { + id: 'changing_subject_and_contact', + creatorId: 'health_center_2_contact', + patientId: 'district_2' + }); + + await mockReport(pouchDb, { + id: 'changing_contact', + creatorId: 'health_center_2_contact', + patientId: 'patient_2' + }); + + await mockReport(pouchDb, { + id: 'changing_subject', + patientId: 'district_2' + }); + + // action + await mergeContacts({ + loserIds: ['district_2'], + winnerId: 'district_1', + }, pouchDb); + + // assert + expect(getWrittenDoc('district_2')).to.deep.eq({ + _id: 'district_2', + _deleted: true, + }); + + expect(getWrittenDoc('health_center_2')).to.deep.eq({ + _id: 'health_center_2', + type: 'health_center', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + parent: parentsToLineage('district_1'), + }); + + expect(getWrittenDoc('clinic_2')).to.deep.eq({ + _id: 'clinic_2', + type: 'clinic', + contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_1'), + parent: parentsToLineage('health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('patient_2')).to.deep.eq({ + _id: 'patient_2', + type: 'person', + parent: parentsToLineage('clinic_2', 'health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('changing_subject_and_contact')).to.deep.eq({ + _id: 'changing_subject_and_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'district_1' + } + }); + + expect(getWrittenDoc('changing_contact')).to.deep.eq({ + _id: 'changing_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'patient_2' + } + }); + + expect(getWrittenDoc('changing_subject')).to.deep.eq({ + _id: 'changing_subject', + form: 'foo', + type: 'data_record', + contact: { + _id: 'dne', + }, + fields: { + patient_uuid: 'district_1' + } + }); + }); +}); diff --git a/test/lib/lineage-constraints.spec.js b/test/lib/lineage-constraints.spec.js index 66c6134d3..bcf574f12 100644 --- a/test/lib/lineage-constraints.spec.js +++ b/test/lib/lineage-constraints.spec.js @@ -11,11 +11,11 @@ const log = require('../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { - describe('getHierarchyErrors', () => { + describe('getMoveContactHierarchyViolations', () => { const scenario = async (contact_types, contactType, parentType) => { const mockDb = { get: () => ({ settings: { contact_types } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: parentType }); - return getHierarchyErrors({ type: contactType }); + const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: parentType }); + return getMoveContactHierarchyViolations({ type: contactType }); }; it('empty rules yields error', async () => expect(await scenario([], 'person', 'health_center')).to.include('unknown type')); @@ -42,22 +42,22 @@ describe('lineage constriants', () => { it('no settings doc requires valid parent type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'dne' }); - const actual = getHierarchyErrors({ type: 'person' }); + const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'dne' }); + const actual = getMoveContactHierarchyViolations({ type: 'person' }); expect(actual).to.include('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getHierarchyErrors({ type: 'dne' }); + const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'clinic' }); + const actual = getMoveContactHierarchyViolations({ type: 'dne' }); expect(actual).to.include('unknown type'); }); it('no settings doc yields not defined', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getHierarchyErrors({ type: 'person' }); + const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'clinic' }); + const actual = getMoveContactHierarchyViolations({ type: 'person' }); expect(actual).to.be.undefined; }); @@ -68,15 +68,15 @@ describe('lineage constriants', () => { it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, undefined); - const actual = getHierarchyErrors({ type: 'district_hospital' }); + const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, undefined); + const actual = getMoveContactHierarchyViolations({ type: 'district_hospital' }); expect(actual).to.be.undefined; }); }); }); describe('getPrimaryContactViolations', () => { - const getHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); + const getMoveContactHierarchyViolations = lineageConstraints.__get__('getPrimaryContactViolations'); describe('on memory pouchdb', async () => { let pouchDb, scenarioCount = 0; @@ -106,13 +106,13 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_2'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); it('cannot move clinic_1_contact to root', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, undefined, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); @@ -120,7 +120,7 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_1'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); @@ -129,7 +129,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_1'); const descendants = await Promise.all(['health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2'].map(id => pouchDb.get(id))); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.be.undefined; }); @@ -142,7 +142,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.deep.include({ _id: 'patient_1' }); }); @@ -153,7 +153,7 @@ describe('lineage constriants', () => { contactDoc.parent._id = 'dne'; - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); }); diff --git a/test/lib/lineage-manipulation.spec.js b/test/lib/lineage-manipulation.spec.js index 7ad0d6e09..1e8947a6c 100644 --- a/test/lib/lineage-manipulation.spec.js +++ b/test/lib/lineage-manipulation.spec.js @@ -1,18 +1,18 @@ const { expect } = require('chai'); -const { replaceLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../src/lib/lineage-manipulation'); +const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../src/lib/lineage-manipulation'); const log = require('../../src/lib/log'); log.level = log.LEVEL_TRACE; const { parentsToLineage } = require('../mock-hierarchies'); describe('lineage manipulation', () => { - describe('replaceLineage', () => { + describe('replaceLineageAfter', () => { const mockReport = data => Object.assign({ _id: 'r', type: 'data_record', contact: parentsToLineage('parent', 'grandparent') }, data); const mockContact = data => Object.assign({ _id: 'c', type: 'person', parent: parentsToLineage('parent', 'grandparent') }, data); it('replace with empty lineage', () => { const mock = mockReport(); - expect(replaceLineage(mock, 'contact', undefined)).to.be.true; + expect(replaceLineageAfter(mock, 'contact', undefined)).to.be.true; expect(mock).to.deep.eq({ _id: 'r', type: 'data_record', @@ -22,7 +22,7 @@ describe('lineage manipulation', () => { it('replace full lineage', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -34,7 +34,7 @@ describe('lineage manipulation', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -45,12 +45,12 @@ describe('lineage manipulation', () => { it('replace empty with empty', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', undefined)).to.be.false; + expect(replaceLineageAfter(mock, 'parent', undefined)).to.be.false; }); it('replace lineage starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; + expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -60,7 +60,7 @@ describe('lineage manipulation', () => { it('replace empty starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', undefined, 'parent')).to.be.true; + expect(replaceLineageAfter(mock, 'parent', undefined, 'parent')).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -70,7 +70,7 @@ describe('lineage manipulation', () => { it('replace starting at non-existant contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; + expect(replaceLineageAfter(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; }); }); diff --git a/test/mock-hierarchies.js b/test/mock-hierarchies.js index d8a2436b3..6d99d8332 100644 --- a/test/mock-hierarchies.js +++ b/test/mock-hierarchies.js @@ -35,13 +35,16 @@ const mockHierarchy = async (db, hierarchy, existingLineage, depth = 0) => { }; const mockReport = async (db, report) => { - const creatorDoc = await db.get(report.creatorId); + const creatorDoc = report.creatorId && await db.get(report.creatorId); await db.put({ _id: report.id, form: 'foo', type: 'data_record', - contact: buildLineage(report.creatorId, creatorDoc.parent), + contact: buildLineage(report.creatorId || 'dne', creatorDoc?.parent), + fields: { + patient_uuid: report.patientId, + } }); }; From 090895c63377a6c63c9d284d26fd28a0b40b2dca Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 12 Nov 2024 16:18:42 -0700 Subject: [PATCH 04/66] Negative cases --- test/fn/merge-contacts.spec.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index 93da88fbf..3aa5a98e3 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -1,9 +1,13 @@ -const { assert, expect } = require('chai'); + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); const rewire = require('rewire'); -const sinon = require('sinon'); const Shared = rewire('../../src/lib/mm-shared'); +chai.use(chaiAsPromised); +const { expect } = chai; + const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); @@ -155,4 +159,28 @@ describe('merge-contacts', () => { } }); }); + + it('throw if loser does not exist', async () => { + const actual = mergeContacts({ + loserIds: ['dne'], + winnerId: 'district_1', + }, pouchDb); + await expect(actual).to.eventually.rejectedWith('could not be found'); + }); + + it('throw if winner does not exist', async () => { + const actual = mergeContacts({ + loserIds: ['district_1'], + winnerId: 'dne', + }, pouchDb); + await expect(actual).to.eventually.rejectedWith('could not be found'); + }); + + it('throw if loser is winner', async () => { + const actual = mergeContacts({ + loserIds: ['district_1', 'district_2'], + winnerId: 'district_2', + }, pouchDb); + await expect(actual).to.eventually.rejectedWith('merge contact with self'); + }); }); From 3e6168c494c53198423984e2d2d53227b49c40fe Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 12 Nov 2024 16:46:21 -0700 Subject: [PATCH 05/66] Fix move-contacts tests again --- src/fn/merge-contacts.js | 14 +++++++------- src/fn/move-contacts.js | 9 +++++---- src/lib/mm-shared.js | 3 --- test/fn/move-contacts.spec.js | 23 +++++++++++------------ 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 0284f68c5..24513b003 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -40,7 +40,7 @@ const mergeContacts = async (options, db) => { _deleted: true, }); - const { prettyPrintDocument } = Shared; + const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; // Check that primary contact is not removed from areas where they are required const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); if (invalidPrimaryContactDoc) { @@ -56,7 +56,7 @@ const mergeContacts = async (options, db) => { minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - const movedReportsCount = await reassignReportSubjects(db, descendantsAndSelf, options, replacementLineage, loserId); + const movedReportsCount = await moveReportsAndReassign(db, descendantsAndSelf, options, replacementLineage, loserId); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -89,14 +89,14 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { .split(',') .filter(Boolean); - if (loserIds.length === 0) { + if (!args.winner) { usage(); - throw Error(`Action "merge-contacts" is missing required list of contacts ${Shared.bold('--losers')} to be merged into the winner`); + throw Error(`Action "merge-contacts" is missing required contact ID ${Shared.bold('--winner')}. Other contacts will be merged into this contact.`); } - if (!args.winner) { + if (loserIds.length === 0) { usage(); - throw Error(`Action "merge-contacts" is missing required parameter ${Shared.bold('--winner')}`); + 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')}`); } return { @@ -127,7 +127,7 @@ ${Shared.bold('OPTIONS')} `); }; -const reassignReportSubjects = async (db, descendantsAndSelf, writeOptions, replacementLineage, loserId) => { +const moveReportsAndReassign = async (db, descendantsAndSelf, writeOptions, replacementLineage, loserId) => { const descendantIds = descendantsAndSelf.map(contact => contact._id); const winnerId = writeOptions.winnerId; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 66c160863..cd4c81b8a 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -29,6 +29,7 @@ const updateLineagesAndStage = async (options, db) => { let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(parentDoc); + const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; for (let contactId of options.contactIds) { const contactDoc = contactDocs[contactId]; const descendantsAndSelf = await Shared.fetch.descendantsOf(db, contactId); @@ -36,14 +37,14 @@ const updateLineagesAndStage = async (options, db) => { // Check that primary contact is not removed from areas where they are required const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${Shared.prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); } - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${Shared.prettyPrintDocument(contactDoc)}.`); + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, contactId); const ancestors = await Shared.fetch.ancestorsOf(db, contactDoc); - trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${Shared.prettyPrintDocument(contactDoc)}.`); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); @@ -54,7 +55,7 @@ const updateLineagesAndStage = async (options, db) => { affectedContactCount += updatedDescendants.length + updatedAncestors.length; affectedReportCount += movedReportsCount; - info(`Staged updates to ${Shared.prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); } info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js index 6783e2d8b..3a61839b2 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/mm-shared.js @@ -9,8 +9,6 @@ const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; const BATCH_SIZE = 10000; -const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; - const prepareDocumentDirectory = ({ docDirectoryPath, force }) => { if (!fs.exists(docDirectoryPath)) { fs.mkdir(docDirectoryPath); @@ -147,7 +145,6 @@ module.exports = { BATCH_SIZE, bold, prepareDocumentDirectory, - prettyPrintDocument, replaceLineageInAncestors, writeDocumentToDisk, fetch, diff --git a/test/fn/move-contacts.spec.js b/test/fn/move-contacts.spec.js index 1d27c6a3b..22d845d5e 100644 --- a/test/fn/move-contacts.spec.js +++ b/test/fn/move-contacts.spec.js @@ -126,6 +126,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), }); }); @@ -170,6 +171,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1'), }); @@ -232,6 +234,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), }); }); @@ -283,6 +286,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), }); }); @@ -321,6 +325,7 @@ describe('move-contacts', () => { _id: 'report_focal', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('focal', 'subcounty', 'county'), }); }); @@ -466,18 +471,6 @@ describe('move-contacts', () => { } }); - it('throw if contact_id does not exist', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['dne'], - parentId: 'clinic_1' - }, pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('could not be found'); - } - }); - it('throw if contact_id is not a contact', async () => { try { await updateLineagesAndStage({ @@ -617,6 +610,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), }); @@ -624,6 +618,7 @@ describe('move-contacts', () => { _id: 'report_2', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), }); @@ -631,6 +626,7 @@ describe('move-contacts', () => { _id: 'report_3', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), }); @@ -693,6 +689,7 @@ describe('move-contacts', () => { _id: 'report_1', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), }); @@ -700,6 +697,7 @@ describe('move-contacts', () => { _id: 'report_2', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), }); @@ -707,6 +705,7 @@ describe('move-contacts', () => { _id: 'report_3', form: 'foo', type: 'data_record', + fields: {}, contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), }); From 2449dd4da8ff17e41e32765bb16ced54ff90e40c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 13:33:15 -0700 Subject: [PATCH 06/66] Some renaming --- src/fn/merge-contacts.js | 66 ++++++++++++++-------------- src/lib/lineage-constraints.js | 18 ++++---- src/lib/mm-shared.js | 10 ++--- test/fn/merge-contacts.spec.js | 80 +++++++++++++++++++++++++++++----- 4 files changed, 115 insertions(+), 59 deletions(-) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 24513b003..8eba4a977 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 c3042ee29..9d64ed499 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 3a61839b2..2bf265043 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 3aa5a98e3..db4278838 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'); }); From b5f8c3be29ceab4c3fbf61528983ca734f08e114 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 15:33:47 -0700 Subject: [PATCH 07/66] Refactor to use options --- src/fn/merge-contacts.js | 177 +++++---------------------------- src/fn/move-contacts.js | 132 +++--------------------- src/lib/lineage-constraints.js | 11 +- src/lib/mm-shared.js | 30 ++---- src/lib/move-contacts-lib.js | 169 +++++++++++++++++++++++++++++++ test/fn/merge-contacts.spec.js | 34 ++----- test/fn/move-contacts.spec.js | 109 +++++--------------- 7 files changed, 260 insertions(+), 402 deletions(-) create mode 100644 src/lib/move-contacts-lib.js diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 8eba4a977..615a681a2 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -2,199 +2,70 @@ const minimist = require('minimist'); const path = require('path'); const environment = require('../lib/environment'); -const lineageManipulation = require('../lib/lineage-manipulation'); -const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); -const { trace, info } = require('../lib/log'); +const { info } = require('../lib/log'); -const Shared = require('../lib/mm-shared'); +const moveContactsLib = require('../lib/move-contacts-lib'); module.exports = { requiresInstance: true, execute: () => { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); - Shared.prepareDocumentDirectory(args); - return mergeContacts(args, db); - } -}; - -const mergeContacts = async (options, db) => { - trace(`Fetching contact details: ${options.keptId}`); - const keptDoc = await Shared.fetch.contact(db, options.keptId); - - 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(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 === removedId); - Shared.writeDocumentToDisk(options, { - _id: self._id, - _rev: self._rev, - _deleted: true, - }); - - const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; - // Check that primary contact is not removed from areas where they are required - const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); - if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + const options = { + sourceIds: args.removeIds, + destinationId: args.keepId, + merge: true, + docDirectoryPath: args.docDirectoryPath, + force: args.force, } - - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); - 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)}.`); - const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); - - minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - - 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; - affectedReportCount += movedReportsCount; - - info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + return moveContactsLib.move(db, options); } - - info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); -}; - -/* -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 (removedDocs, constraints) => { - Object.values(removedDocs).forEach(doc => { - const hierarchyError = constraints.getMergeContactHierarchyViolations(doc); - if (hierarchyError) { - throw Error(`Hierarchy Constraints: ${hierarchyError}`); - } - }); }; // Parses extraArgs and asserts if required parameters are not present const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const removedIds = (args.removed || '') + const removeIds = (args.remove || '') .split(',') .filter(Boolean); - if (!args.kept) { + if (!args.keep) { usage(); - throw Error(`Action "merge-contacts" is missing required contact ID ${Shared.bold('--kept')}. Other contacts will be merged into this contact.`); + throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--keep')}. Other contacts will be merged into this contact.`); } - if (removedIds.length === 0) { + if (removeIds.length === 0) { usage(); - 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')}`); + throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--remove')}. These contacts will be merged into the contact specified by ${bold('--keep')}`); } return { - keptId: args.kept, - removedIds, + keepId: args.keep, + removeIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; }; +const bold = text => `\x1b[1m${text}\x1b[0m`; const usage = () => { info(` -${Shared.bold('cht-conf\'s merge-contacts action')} +${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 -- --kept= --removed=, +${bold('USAGE')} +cht --local merge-contacts -- --keep= --remove=, -${Shared.bold('OPTIONS')} ---kept= +${bold('OPTIONS')} +--keep= Specifies the ID of the contact that should have all other contact data merged into it. ---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. +--remove=, + A comma delimited list of IDs of contacts which will be deleted and all of their data will be merged into the keep contact. --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); }; - -const moveReportsAndReassign = async (db, descendantsAndSelf, writeOptions, replacementLineage, removedId) => { - const descendantIds = descendantsAndSelf.map(contact => contact._id); - 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, removedId, skip); - - 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] === removedId) { - report[subjectId] = keptId; - updated = true; - } - - if (report.fields[subjectId] === removedId) { - report.fields[subjectId] = keptId; - updated = true; - } - - if (updated) { - const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); - if (!isAlreadyUpdated) { - updatedReports.push(report); - } - } - } - }); - - minifyLineageAndWriteToDisk(updatedReports, writeOptions); - - skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= Shared.BATCH_SIZE); - - return skip; -}; - -// Shared? -const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineageAt(doc, 'contact', replaceWith, startingFromIdInLineage)) { - agg.push(doc); - } - return agg; -}, []); - -const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { - docs.forEach(doc => { - lineageManipulation.minifyLineagesInDoc(doc); - Shared.writeDocumentToDisk(parsedArgs, doc); - }); -}; - -const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => { - // skip top-level because it is now being deleted - if (doc._id === contactId) { - return agg; - } - - const parentWasUpdated = lineageManipulation.replaceLineageAt(doc, 'parent', replacementLineage, contactId); - const contactWasUpdated = lineageManipulation.replaceLineageAt(doc, 'contact', replacementLineage, contactId); - if (parentWasUpdated || contactWasUpdated) { - agg.push(doc); - } - return agg; -}, []); diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index cd4c81b8a..0b5ae2046 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -2,90 +2,25 @@ const minimist = require('minimist'); const path = require('path'); const environment = require('../lib/environment'); -const lineageManipulation = require('../lib/lineage-manipulation'); -const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); -const { trace, info } = require('../lib/log'); +const { info } = require('../lib/log'); -const Shared = require('../lib/mm-shared'); +const moveContactsLib = require('../lib/move-contacts-lib'); module.exports = { requiresInstance: true, execute: () => { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); - Shared.prepareDocumentDirectory(args); - return updateLineagesAndStage(args, db); - } -}; - -const updateLineagesAndStage = async (options, db) => { - trace(`Fetching contact details for parent: ${options.parentId}`); - const parentDoc = await Shared.fetch.contact(db, options.parentId); - - const constraints = await lineageConstraints(db, parentDoc); - const contactDocs = await Shared.fetch.contactList(db, options.contactIds); - await validateContacts(contactDocs, constraints); - - let affectedContactCount = 0, affectedReportCount = 0; - const replacementLineage = lineageManipulation.createLineageFromDoc(parentDoc); - const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; - for (let contactId of options.contactIds) { - const contactDoc = contactDocs[contactId]; - const descendantsAndSelf = await Shared.fetch.descendantsOf(db, contactId); - - // Check that primary contact is not removed from areas where they are required - const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); - if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + const options = { + sourceIds: args.contactIds, + destinationId: args.parentId, + merge: false, + docDirectoryPath: args.docDirectoryPath, + force: args.force, } - - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, contactId); - - const ancestors = await Shared.fetch.ancestorsOf(db, contactDoc); - trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); - - minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - - const movedReportsCount = await moveReports(db, descendantsAndSelf, options, replacementLineage, contactId); - trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); - - affectedContactCount += updatedDescendants.length + updatedAncestors.length; - affectedReportCount += movedReportsCount; - - info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + return moveContactsLib.move(db, options); } - - info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); -}; - -/* -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 (contactDocs, constraints) => { - Object.values(contactDocs).forEach(doc => { - const hierarchyError = constraints.getMoveContactHierarchyViolations(doc); - if (hierarchyError) { - throw Error(`Hierarchy Constraints: ${hierarchyError}`); - } - }); - - /* - It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. - For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. - */ - const contactIds = Object.keys(contactDocs); - Object.values(contactDocs) - .forEach(doc => { - const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; - const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); - if (violatingParentId) { - throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); - } - }); }; // Parses extraArgs and asserts if required parameters are not present @@ -114,15 +49,16 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { }; }; +const bold = text => `\x1b[1m${text}\x1b[0m`; const usage = () => { info(` -${Shared.bold('cht-conf\'s move-contacts action')} +${bold('cht-conf\'s move-contacts action')} When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another. -${Shared.bold('USAGE')} +${bold('USAGE')} cht --local move-contacts -- --contacts=, --parent= -${Shared.bold('OPTIONS')} +${bold('OPTIONS')} --contacts=, A comma delimited list of ids of contacts to be moved. @@ -133,45 +69,3 @@ ${Shared.bold('OPTIONS')} Specifies the folder used to store the documents representing the changes in hierarchy. `); }; - -const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLineage, contactId) => { - const contactIds = descendantsAndSelf.map(contact => contact._id); - - let skip = 0; - let reportDocsBatch; - do { - info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); - reportDocsBatch = await Shared.fetch.reportsCreatedBy(db, contactIds, skip); - - const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId); - minifyLineageAndWriteToDisk(updatedReports, writeOptions); - - skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= Shared.BATCH_SIZE); - - return skip; -}; - -const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { - docs.forEach(doc => { - lineageManipulation.minifyLineagesInDoc(doc); - Shared.writeDocumentToDisk(parsedArgs, doc); - }); -}; - -const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineageAfter(doc, 'contact', replaceWith, startingFromIdInLineage)) { - agg.push(doc); - } - return agg; -}, []); - -const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => { - const startingFromIdInLineage = doc._id === contactId ? undefined : contactId; - const parentWasUpdated = lineageManipulation.replaceLineageAfter(doc, 'parent', replacementLineage, startingFromIdInLineage); - const contactWasUpdated = lineageManipulation.replaceLineageAfter(doc, 'contact', replacementLineage, contactId); - if (parentWasUpdated || contactWasUpdated) { - agg.push(doc); - } - return agg; -}, []); diff --git a/src/lib/lineage-constraints.js b/src/lib/lineage-constraints.js index 9d64ed499..d5b1cb5a5 100644 --- a/src/lib/lineage-constraints.js +++ b/src/lib/lineage-constraints.js @@ -3,7 +3,7 @@ const { trace } = log; const { pluckIdsFromLineage } = require('./lineage-manipulation'); -const lineageConstraints = async (repository, parentDoc) => { +const lineageConstraints = async (repository, parentDoc, options) => { let mapTypeToAllowedParents; try { const { settings } = await repository.get('settings'); @@ -33,8 +33,13 @@ const lineageConstraints = async (repository, parentDoc) => { return { getPrimaryContactViolations: (contactDoc, descendantDocs) => getPrimaryContactViolations(repository, contactDoc, parentDoc, descendantDocs), - getMoveContactHierarchyViolations: contactDoc => getMoveContactHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc), - getMergeContactHierarchyViolations: contactDoc => getMergeContactHierarchyViolations(contactDoc, parentDoc), + validate: (contactDoc) => { + if (options.merge) { + return getMergeContactHierarchyViolations(contactDoc, parentDoc); + } + + return getMoveContactHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc); + }, }; }; diff --git a/src/lib/mm-shared.js b/src/lib/mm-shared.js index 2bf265043..977c52957 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/mm-shared.js @@ -93,26 +93,19 @@ const fetch = { .filter(doc => doc && doc.type !== 'tombstone'); }, - reportsCreatedBy: async (db, contactIds, skip) => { - const reports = await db.query('medic-client/reports_by_freetext', { - keys: contactIds.map(id => [`contact:${id}`]), - include_docs: true, - limit: BATCH_SIZE, - skip, - }); - - return reports.rows.map(row => row.doc); - }, + reportsCreatedByOrAt: async (db, createdByIds, createdAtId, skip) => { + const createdByKeys = createdByIds.map(descendantId => [`contact:${descendantId}`]); + const createdAtKeys = createdAtId ? [ + [`patient_id:${createdAtId}`], + [`patient_uuid:${createdAtId}`], + [`place_id:${createdAtId}`], + [`place_uuid:${createdAtId}`] + ] : []; - 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:${removedId}`], - [`patient_uuid:${removedId}`], - [`place_id:${removedId}`], - [`place_uuid:${removedId}`], + ...createdByKeys, + ...createdAtKeys, ], include_docs: true, limit: BATCH_SIZE, @@ -138,12 +131,9 @@ const fetch = { }, }; -const bold = text => `\x1b[1m${text}\x1b[0m`; - module.exports = { HIERARCHY_ROOT, BATCH_SIZE, - bold, prepareDocumentDirectory, replaceLineageInAncestors, writeDocumentToDisk, diff --git a/src/lib/move-contacts-lib.js b/src/lib/move-contacts-lib.js new file mode 100644 index 000000000..d54b6484d --- /dev/null +++ b/src/lib/move-contacts-lib.js @@ -0,0 +1,169 @@ +const lineageManipulation = require('../lib/lineage-manipulation'); +const lineageConstraints = require('../lib/lineage-constraints'); +const { trace, info } = require('../lib/log'); + +const Shared = require('../lib/mm-shared'); + +module.exports = (options) => { + const move = async (sourceIds, destinationId, db) => { + Shared.prepareDocumentDirectory(options); + trace(`Fetching contact details: ${destinationId}`); + const destinationDoc = await Shared.fetch.contact(db, destinationId); + + const constraints = await lineageConstraints(db, destinationDoc, options); + const sourceDocs = await Shared.fetch.contactList(db, sourceIds); + await validateContacts(sourceDocs, constraints); + + let affectedContactCount = 0, affectedReportCount = 0; + const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); + for (let sourceId of sourceIds) { + const sourceDoc = sourceDocs[sourceId]; + const descendantsAndSelf = await Shared.fetch.descendantsOf(db, sourceId); + + if (options.merge) { + const self = descendantsAndSelf.find(d => d._id === sourceId); + Shared.writeDocumentToDisk(options, { + _id: self._id, + _rev: self._rev, + _deleted: true, + }); + } + + const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; + // Check that primary contact is not removed from areas where they are required + const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(sourceDoc, descendantsAndSelf); + if (invalidPrimaryContactDoc) { + throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + } + + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, sourceId); + + const ancestors = await Shared.fetch.ancestorsOf(db, sourceDoc); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); + + minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors]); + + const movedReportsCount = await moveReports(db, descendantsAndSelf, replacementLineage, sourceId, destinationId); + trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); + + affectedContactCount += updatedDescendants.length + updatedAncestors.length; + affectedReportCount += movedReportsCount; + + info(`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + } + + info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); + }; + + /* + 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 (sourceDocs, constraints) => { + Object.values(sourceDocs).forEach(doc => { + const hierarchyError = constraints.validate(doc); + if (hierarchyError) { + throw Error(`Hierarchy Constraints: ${hierarchyError}`); + } + }); + + /* + It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. + For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. + */ + const contactIds = Object.keys(sourceDocs); + Object.values(sourceDocs) + .forEach(doc => { + const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; + const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); + if (violatingParentId) { + throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); + } + }); + }; + + const moveReports = async (db, descendantsAndSelf, replacementLineage, sourceId, destinationId) => { + const descendantIds = descendantsAndSelf.map(contact => contact._id); + + let skip = 0; + let reportDocsBatch; + do { + info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); + const createdAtId = options.merge && sourceId; + reportDocsBatch = await Shared.fetch.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + + const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); + + if (options.merge) { + reportDocsBatch.forEach(report => { + let updated = false; + const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + for (const subjectId of subjectIds) { + if (report[subjectId] === sourceId) { + report[subjectId] = destinationId; + updated = true; + } + + if (report.fields[subjectId] === sourceId) { + report.fields[subjectId] = destinationId; + updated = true; + } + + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); + } + } + } + }); + } + + minifyLineageAndWriteToDisk(updatedReports); + + skip += reportDocsBatch.length; + } while (reportDocsBatch.length >= Shared.BATCH_SIZE); + + return skip; + }; + + const minifyLineageAndWriteToDisk = (docs) => { + docs.forEach(doc => { + lineageManipulation.minifyLineagesInDoc(doc); + Shared.writeDocumentToDisk(options, doc); + }); + }; + + const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { + const operation = options.merge ? lineageManipulation.replaceLineageAt : lineageManipulation.replaceLineageAfter; + if (operation(doc, 'contact', replaceWith, startingFromIdInLineage)) { + agg.push(doc); + } + return agg; + }, []); + + const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, destinationId) => descendantsAndSelf.reduce((agg, doc) => { + const startingFromIdInLineage = options.merge ? destinationId : + doc._id === destinationId ? undefined : destinationId; + + // skip top-level because it will be deleted + if (options.merge) { + if (doc._id === destinationId) { + return agg; + } + } + + const lineageOperation = options.merge ? lineageManipulation.replaceLineageAt : lineageManipulation.replaceLineageAfter; + const parentWasUpdated = lineageOperation(doc, 'parent', replacementLineage, startingFromIdInLineage); + const contactWasUpdated = lineageOperation(doc, 'contact', replacementLineage, destinationId); + if (parentWasUpdated || contactWasUpdated) { + agg.push(doc); + } + return agg; + }, []); + + return { move }; +}; + diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index db4278838..df4e5e19f 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -12,10 +12,11 @@ const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const mergeContactsModule = rewire('../../src/fn/merge-contacts'); -mergeContactsModule.__set__('Shared', Shared); +const MoveContactsLib = rewire('../../src/lib/move-contacts-lib'); +MoveContactsLib.__set__('Shared', Shared); + +const move = MoveContactsLib({ merge: true }).move; -const mergeContacts = mergeContactsModule.__get__('mergeContacts'); const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); const contacts_by_depth = { @@ -102,11 +103,8 @@ describe('merge-contacts', () => { patientId: 'district_2' }); - // action - await mergeContacts({ - removedIds: ['district_2'], - keptId: 'district_1', - }, pouchDb); + // action + await move(['district_2'], 'district_1', pouchDb); // assert expectWrittenDocs([ @@ -190,10 +188,7 @@ describe('merge-contacts', () => { }); // action - await mergeContacts({ - removedIds: ['patient_2'], - keptId: 'patient_1', - }, pouchDb); + await move(['patient_2'], 'patient_1', pouchDb); await expectWrittenDocs(['patient_2', 'pat2']); @@ -217,26 +212,17 @@ describe('merge-contacts', () => { xit('write to ancestors', () => {}); it('throw if removed does not exist', async () => { - const actual = mergeContacts({ - removedIds: ['dne'], - keptId: 'district_1', - }, pouchDb); + const actual = move(['dne'], 'district_1', pouchDb); await expect(actual).to.eventually.rejectedWith('could not be found'); }); it('throw if kept does not exist', async () => { - const actual = mergeContacts({ - removedIds: ['district_1'], - keptId: 'dne', - }, pouchDb); + const actual = move(['district_1'], 'dne', pouchDb); await expect(actual).to.eventually.rejectedWith('could not be found'); }); it('throw if removed is kept', async () => { - const actual = mergeContacts({ - removedIds: ['district_1', 'district_2'], - keptId: 'district_2', - }, pouchDb); + const actual = move(['district_1', 'district_2'], 'district_2', pouchDb); await expect(actual).to.eventually.rejectedWith('merge contact with self'); }); }); diff --git a/test/fn/move-contacts.spec.js b/test/fn/move-contacts.spec.js index 22d845d5e..66847ee70 100644 --- a/test/fn/move-contacts.spec.js +++ b/test/fn/move-contacts.spec.js @@ -1,20 +1,18 @@ const { assert, expect } = require('chai'); const rewire = require('rewire'); const sinon = require('sinon'); -const fs = require('../../src/lib/sync-fs'); -const environment = require('../../src/lib/environment'); +const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); const Shared = rewire('../../src/lib/mm-shared'); const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const moveContactsModule = rewire('../../src/fn/move-contacts'); -moveContactsModule.__set__('Shared', Shared); +const MoveContactsLib = rewire('../../src/lib/move-contacts-lib'); +MoveContactsLib.__set__('Shared', Shared); -const updateLineagesAndStage = moveContactsModule.__get__('updateLineagesAndStage'); -const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); +const move = MoveContactsLib({ merge: false }).move; const contacts_by_depth = { // eslint-disable-next-line quotes @@ -91,10 +89,7 @@ describe('move-contacts', () => { afterEach(async () => pouchDb.destroy()); it('move health_center_1 to district_2', async () => { - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); + await move(['health_center_1'], 'district_2', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -136,10 +131,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: [] }]); - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'root', - }, pouchDb); + await move(['health_center_1'], 'root', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -192,10 +184,7 @@ describe('move-contacts', () => { it('move district_1 from root', async () => { await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'district_2', - }, pouchDb); + await move(['district_1'], 'district_2', pouchDb); expect(getWrittenDoc('district_1')).to.deep.eq({ _id: 'district_1', @@ -251,10 +240,7 @@ describe('move-contacts', () => { { id: 'district_hospital', parents: ['county'] }, ]); - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'county_1', - }, pouchDb); + await move(['district_1'], 'county_1', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -309,10 +295,7 @@ describe('move-contacts', () => { creatorId: 'focal', }); - await updateLineagesAndStage({ - contactIds: ['focal'], - parentId: 'subcounty', - }, pouchDb); + await move(['focal'], 'subcounty', pouchDb); expect(getWrittenDoc('focal')).to.deep.eq({ _id: 'focal', @@ -357,10 +340,7 @@ describe('move-contacts', () => { parent: parentsToLineage(), }); - await updateLineagesAndStage({ - contactIds: ['t_patient_1'], - parentId: 't_clinic_2', - }, pouchDb); + await move(['t_patient_1'], 't_clinic_2', pouchDb); expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ _id: 't_health_center_1', @@ -381,10 +361,7 @@ describe('move-contacts', () => { // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' it('district_hospital with empty string parent is not preserved', async () => { await upsert('district_2', { parent: '', type: 'district_hospital' }); - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); + await move(['health_center_1'], 'district_2', pouchDb); expect(getWrittenDoc('health_center_1')).to.deep.eq({ _id: 'health_center_1', @@ -412,11 +389,7 @@ describe('move-contacts', () => { await upsert('clinic_1', clinic); await upsert('patient_1', patient); - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'district_2', - }, pouchDb); - + await move(['clinic_1'], 'district_2', pouchDb); expect(getWrittenDoc('clinic_1')).to.deep.eq({ _id: 'clinic_1', @@ -437,10 +410,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); try { - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'clinic_1', - }, pouchDb); + await move(['health_center_1'], 'clinic_1', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('circular'); @@ -449,10 +419,7 @@ describe('move-contacts', () => { it('throw if parent does not exist', async () => { try { - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'dne_parent_id' - }, pouchDb); + await move(['clinic_1'], 'dne_parent_id', pouchDb); assert.fail('should throw when parent is not defined'); } catch (err) { expect(err.message).to.include('could not be found'); @@ -461,10 +428,7 @@ describe('move-contacts', () => { it('throw when altering same lineage', async () => { try { - await updateLineagesAndStage({ - contactIds: ['patient_1', 'health_center_1'], - parentId: 'district_2', - }, pouchDb); + await move(['patient_1', 'health_center_1'], 'district_2', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('same lineage'); @@ -473,10 +437,7 @@ describe('move-contacts', () => { it('throw if contact_id is not a contact', async () => { try { - await updateLineagesAndStage({ - contactIds: ['report_1'], - parentId: 'clinic_1' - }, pouchDb); + await move(['report_1'], 'clinic_1', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('unknown type'); @@ -485,11 +446,7 @@ describe('move-contacts', () => { it('throw if moving primary contact of parent', async () => { try { - await updateLineagesAndStage({ - contactIds: ['clinic_1_contact'], - parentId: 'district_1' - }, pouchDb); - + await move(['clinic_1_contact'], 'district_1', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('primary contact'); @@ -499,11 +456,7 @@ describe('move-contacts', () => { it('throw if setting parent to self', async () => { await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); try { - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'clinic_1' - }, pouchDb); - + await move(['clinic_1'], 'clinic_1', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('circular'); @@ -514,19 +467,15 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); try { - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'district_2', - }, pouchDb); - + await move(['district_1'], 'district_2', pouchDb); assert.fail('Expected error'); } catch (err) { expect(err.message).to.include('parent of type'); } }); - describe('parseExtraArgs', () => { - const parseExtraArgs = moveContactsModule.__get__('parseExtraArgs'); + xdescribe('parseExtraArgs', () => { + // const parseExtraArgs = MoveContactsLib.__get__('parseExtraArgs'); it('undefined arguments', () => { expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required list of contacts'); }); @@ -538,8 +487,8 @@ describe('move-contacts', () => { it('contacts and parents', () => { const args = ['--contacts=food,is,tasty', '--parent=bar', '--docDirectoryPath=/', '--force=hi']; expect(parseExtraArgs(__dirname, args)).to.deep.eq({ - contactIds: ['food', 'is', 'tasty'], - parentId: 'bar', + sourceIds: ['food', 'is', 'tasty'], + destinationId: 'bar', force: true, docDirectoryPath: '/', }); @@ -575,11 +524,8 @@ describe('move-contacts', () => { Shared.BATCH_SIZE = 1; sinon.spy(pouchDb, 'query'); - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); - + await move(['health_center_1'], 'district_2', pouchDb); + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', type: 'person', @@ -654,10 +600,7 @@ describe('move-contacts', () => { Shared.BATCH_SIZE = 2; sinon.spy(pouchDb, 'query'); - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_1', - }, pouchDb); + await move(['health_center_1'], 'district_1', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', From 1273fb620ceb39d552d57ec686f1ce5157ef5c8e Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 15:44:53 -0700 Subject: [PATCH 08/66] Move folder structure --- src/fn/merge-contacts.js | 8 +++----- src/fn/move-contacts.js | 8 +++----- src/lib/{ => move-contacts}/lineage-constraints.js | 2 +- src/lib/{ => move-contacts}/lineage-manipulation.js | 0 src/lib/{ => move-contacts}/mm-shared.js | 6 +++--- src/lib/{ => move-contacts}/move-contacts-lib.js | 8 ++++---- test/lib/{ => move-contacts}/lineage-constraints.spec.js | 6 +++--- test/lib/{ => move-contacts}/lineage-manipulation.spec.js | 6 +++--- test/{fn => lib/move-contacts}/merge-contacts.spec.js | 6 +++--- test/{fn => lib/move-contacts}/mm-shared.spec.js | 8 ++++---- test/{fn => lib/move-contacts}/move-contacts.spec.js | 6 +++--- 11 files changed, 30 insertions(+), 34 deletions(-) rename src/lib/{ => move-contacts}/lineage-constraints.js (99%) rename src/lib/{ => move-contacts}/lineage-manipulation.js (100%) rename src/lib/{ => move-contacts}/mm-shared.js (97%) rename src/lib/{ => move-contacts}/move-contacts-lib.js (96%) rename test/lib/{ => move-contacts}/lineage-constraints.spec.js (97%) rename test/lib/{ => move-contacts}/lineage-manipulation.spec.js (95%) rename test/{fn => lib/move-contacts}/merge-contacts.spec.js (97%) rename test/{fn => lib/move-contacts}/mm-shared.spec.js (85%) rename test/{fn => lib/move-contacts}/move-contacts.spec.js (99%) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 615a681a2..13c3e9a19 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -5,7 +5,7 @@ const environment = require('../lib/environment'); const pouch = require('../lib/db'); const { info } = require('../lib/log'); -const moveContactsLib = require('../lib/move-contacts-lib'); +const MoveContactsLib = require('../lib/move-contacts/move-contacts-lib'); module.exports = { requiresInstance: true, @@ -13,13 +13,11 @@ module.exports = { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); const options = { - sourceIds: args.removeIds, - destinationId: args.keepId, merge: true, docDirectoryPath: args.docDirectoryPath, force: args.force, - } - return moveContactsLib.move(db, options); + }; + return MoveContactsLib(options).move(args.removeIds, args.keepId, db); } }; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 0b5ae2046..7e1e68a56 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -5,7 +5,7 @@ const environment = require('../lib/environment'); const pouch = require('../lib/db'); const { info } = require('../lib/log'); -const moveContactsLib = require('../lib/move-contacts-lib'); +const MoveContactsLib = require('../lib/move-contacts/move-contacts-lib'); module.exports = { requiresInstance: true, @@ -13,13 +13,11 @@ module.exports = { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); const options = { - sourceIds: args.contactIds, - destinationId: args.parentId, merge: false, docDirectoryPath: args.docDirectoryPath, force: args.force, - } - return moveContactsLib.move(db, options); + }; + return MoveContactsLib(options).move(args.contactIds, args.parentId, db); } }; diff --git a/src/lib/lineage-constraints.js b/src/lib/move-contacts/lineage-constraints.js similarity index 99% rename from src/lib/lineage-constraints.js rename to src/lib/move-contacts/lineage-constraints.js index d5b1cb5a5..ff7b0992f 100644 --- a/src/lib/lineage-constraints.js +++ b/src/lib/move-contacts/lineage-constraints.js @@ -1,4 +1,4 @@ -const log = require('./log'); +const log = require('../log'); const { trace } = log; const { pluckIdsFromLineage } = require('./lineage-manipulation'); diff --git a/src/lib/lineage-manipulation.js b/src/lib/move-contacts/lineage-manipulation.js similarity index 100% rename from src/lib/lineage-manipulation.js rename to src/lib/move-contacts/lineage-manipulation.js diff --git a/src/lib/mm-shared.js b/src/lib/move-contacts/mm-shared.js similarity index 97% rename from src/lib/mm-shared.js rename to src/lib/move-contacts/mm-shared.js index 977c52957..37f6f7fc7 100644 --- a/src/lib/mm-shared.js +++ b/src/lib/move-contacts/mm-shared.js @@ -1,9 +1,9 @@ const _ = require('lodash'); const path = require('path'); -const userPrompt = require('./user-prompt'); -const fs = require('./sync-fs'); -const { warn, trace } = require('./log'); +const userPrompt = require('../user-prompt'); +const fs = require('../sync-fs'); +const { warn, trace } = require('../log'); const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; diff --git a/src/lib/move-contacts-lib.js b/src/lib/move-contacts/move-contacts-lib.js similarity index 96% rename from src/lib/move-contacts-lib.js rename to src/lib/move-contacts/move-contacts-lib.js index d54b6484d..5391d3459 100644 --- a/src/lib/move-contacts-lib.js +++ b/src/lib/move-contacts/move-contacts-lib.js @@ -1,8 +1,8 @@ -const lineageManipulation = require('../lib/lineage-manipulation'); -const lineageConstraints = require('../lib/lineage-constraints'); -const { trace, info } = require('../lib/log'); +const lineageManipulation = require('./lineage-manipulation'); +const lineageConstraints = require('./lineage-constraints'); +const { trace, info } = require('../log'); -const Shared = require('../lib/mm-shared'); +const Shared = require('./mm-shared'); module.exports = (options) => { const move = async (sourceIds, destinationId, db) => { diff --git a/test/lib/lineage-constraints.spec.js b/test/lib/move-contacts/lineage-constraints.spec.js similarity index 97% rename from test/lib/lineage-constraints.spec.js rename to test/lib/move-contacts/lineage-constraints.spec.js index bcf574f12..52e612c61 100644 --- a/test/lib/lineage-constraints.spec.js +++ b/test/lib/move-contacts/lineage-constraints.spec.js @@ -4,10 +4,10 @@ const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const { mockHierarchy } = require('../mock-hierarchies'); +const { mockHierarchy } = require('../../mock-hierarchies'); -const lineageConstraints = rewire('../../src/lib/lineage-constraints'); -const log = require('../../src/lib/log'); +const lineageConstraints = rewire('../../../src/lib/move-contacts/lineage-constraints'); +const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { diff --git a/test/lib/lineage-manipulation.spec.js b/test/lib/move-contacts/lineage-manipulation.spec.js similarity index 95% rename from test/lib/lineage-manipulation.spec.js rename to test/lib/move-contacts/lineage-manipulation.spec.js index 1e8947a6c..1a4fc467a 100644 --- a/test/lib/lineage-manipulation.spec.js +++ b/test/lib/move-contacts/lineage-manipulation.spec.js @@ -1,9 +1,9 @@ const { expect } = require('chai'); -const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../src/lib/lineage-manipulation'); -const log = require('../../src/lib/log'); +const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/move-contacts/lineage-manipulation'); +const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; -const { parentsToLineage } = require('../mock-hierarchies'); +const { parentsToLineage } = require('../../mock-hierarchies'); describe('lineage manipulation', () => { describe('replaceLineageAfter', () => { diff --git a/test/fn/merge-contacts.spec.js b/test/lib/move-contacts/merge-contacts.spec.js similarity index 97% rename from test/fn/merge-contacts.spec.js rename to test/lib/move-contacts/merge-contacts.spec.js index df4e5e19f..2d17eddcf 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/lib/move-contacts/merge-contacts.spec.js @@ -3,7 +3,7 @@ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const rewire = require('rewire'); -const Shared = rewire('../../src/lib/mm-shared'); +const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); chai.use(chaiAsPromised); const { expect } = chai; @@ -12,12 +12,12 @@ const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const MoveContactsLib = rewire('../../src/lib/move-contacts-lib'); +const MoveContactsLib = rewire('../../../src/lib/move-contacts/move-contacts-lib'); MoveContactsLib.__set__('Shared', Shared); const move = MoveContactsLib({ merge: true }).move; -const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); +const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); const contacts_by_depth = { // eslint-disable-next-line quotes diff --git a/test/fn/mm-shared.spec.js b/test/lib/move-contacts/mm-shared.spec.js similarity index 85% rename from test/fn/mm-shared.spec.js rename to test/lib/move-contacts/mm-shared.spec.js index 8902613cd..30cb03b61 100644 --- a/test/fn/mm-shared.spec.js +++ b/test/lib/move-contacts/mm-shared.spec.js @@ -2,10 +2,10 @@ const { assert } = require('chai'); const rewire = require('rewire'); const sinon = require('sinon'); -const environment = require('../../src/lib/environment'); -const fs = require('../../src/lib/sync-fs'); -const Shared = rewire('../../src/lib/mm-shared'); -const userPrompt = rewire('../../src/lib/user-prompt'); +const environment = require('../../../src/lib/environment'); +const fs = require('../../../src/lib/sync-fs'); +const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); +const userPrompt = rewire('../../../src/lib/user-prompt'); describe('mm-shared', () => { diff --git a/test/fn/move-contacts.spec.js b/test/lib/move-contacts/move-contacts.spec.js similarity index 99% rename from test/fn/move-contacts.spec.js rename to test/lib/move-contacts/move-contacts.spec.js index 66847ee70..a15a4170a 100644 --- a/test/fn/move-contacts.spec.js +++ b/test/lib/move-contacts/move-contacts.spec.js @@ -2,14 +2,14 @@ const { assert, expect } = require('chai'); const rewire = require('rewire'); const sinon = require('sinon'); -const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); -const Shared = rewire('../../src/lib/mm-shared'); +const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); +const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const MoveContactsLib = rewire('../../src/lib/move-contacts-lib'); +const MoveContactsLib = rewire('../../../src/lib/move-contacts/move-contacts-lib'); MoveContactsLib.__set__('Shared', Shared); const move = MoveContactsLib({ merge: false }).move; From 25ad23087615fe18c7194ada770f767d3aff214b Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 16:30:11 -0700 Subject: [PATCH 09/66] Lineage Constraints --- src/lib/move-contacts/lineage-constraints.js | 181 +++++++++++------- src/lib/move-contacts/move-contacts-lib.js | 35 +--- .../move-contacts/lineage-constraints.spec.js | 64 ++++--- test/lib/move-contacts/merge-contacts.spec.js | 2 +- 4 files changed, 148 insertions(+), 134 deletions(-) diff --git a/src/lib/move-contacts/lineage-constraints.js b/src/lib/move-contacts/lineage-constraints.js index ff7b0992f..91d2845d1 100644 --- a/src/lib/move-contacts/lineage-constraints.js +++ b/src/lib/move-contacts/lineage-constraints.js @@ -1,45 +1,44 @@ const log = require('../log'); const { trace } = log; -const { pluckIdsFromLineage } = require('./lineage-manipulation'); +const lineageManipulation = require('./lineage-manipulation'); -const lineageConstraints = async (repository, parentDoc, options) => { - let mapTypeToAllowedParents; - try { - const { settings } = await repository.get('settings'); - const { contact_types } = settings; +module.exports = async (db, options = {}) => { + const mapTypeToAllowedParents = await fetchAllowedParents(db); - if (Array.isArray(contact_types)) { - trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.'); - mapTypeToAllowedParents = contact_types - .filter(rule => rule) - .reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {}); + const getHierarchyErrors = (sourceDoc, destinationDoc) => { + if (options.merge) { + return getMergeViolations(sourceDoc, destinationDoc); } - } catch (err) { - if (err.name !== 'not_found') { - throw err; - } - } - if (!mapTypeToAllowedParents) { - trace('Default hierarchy constraints will be enforced.'); - mapTypeToAllowedParents = { - district_hospital: [], - health_center: ['district_hospital'], - clinic: ['health_center'], - person: ['district_hospital', 'health_center', 'clinic'], - }; - } + return getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc); + }; return { - getPrimaryContactViolations: (contactDoc, descendantDocs) => getPrimaryContactViolations(repository, contactDoc, parentDoc, descendantDocs), - validate: (contactDoc) => { - if (options.merge) { - return getMergeContactHierarchyViolations(contactDoc, parentDoc); - } - - return getMoveContactHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc); - }, + getPrimaryContactViolations: (sourceDoc, destinationDoc, descendantDocs) => getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs), + getHierarchyErrors, + assertHierarchyErrors: (sourceDocs, destinationDoc) => { + sourceDocs.forEach(sourceDoc => { + const hierarchyError = getHierarchyErrors(sourceDoc, destinationDoc); + if (hierarchyError) { + throw Error(`Hierarchy Constraints: ${hierarchyError}`); + } + }); + + /* + It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. + For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. + */ + const contactIds = sourceDocs.map(doc => doc._id); + sourceDocs + .forEach(doc => { + const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; + const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); + if (violatingParentId) { + throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); + } + }); + } }; }; @@ -47,51 +46,62 @@ const lineageConstraints = async (repository, parentDoc, options) => { Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ -const getMoveContactHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) => { - // TODO reuse this code - const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); - const contactType = getContactType(contactDoc); - const parentType = getContactType(parentDoc); - if (!contactType) return 'contact required attribute "type" is undefined'; - if (parentDoc && !parentType) return `parent contact "${parentDoc._id}" required attribute "type" is undefined`; - if (!mapTypeToAllowedParents) return 'hierarchy constraints are undefined'; - - const rulesForContact = mapTypeToAllowedParents[contactType]; - if (!rulesForContact) return `cannot move contact with unknown type '${contactType}'`; - - const isPermittedMoveToRoot = !parentDoc && rulesForContact.length === 0; - if (!isPermittedMoveToRoot && !rulesForContact.includes(parentType)) return `contacts of type '${contactType}' cannot have parent of type '${parentType}'`; - - if (parentDoc && contactDoc._id) { - const parentAncestry = [parentDoc._id, ...pluckIdsFromLineage(parentDoc.parent)]; - if (parentAncestry.includes(contactDoc._id)) { - return `Circular hierarchy: Cannot set parent of contact '${contactDoc._id}' as it would create a circular hierarchy.`; +const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + if (commonViolations) { + return commonViolations; + } + + if (!mapTypeToAllowedParents) { + return 'hierarchy constraints are undefined'; + } + + const sourceContactType = getContactType(sourceDoc); + const destinationType = getContactType(destinationDoc); + const rulesForContact = mapTypeToAllowedParents[sourceContactType]; + if (!rulesForContact) { + return `cannot move contact with unknown type '${sourceContactType}'`; + } + + const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; + if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { + return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; + } + + if (destinationDoc && sourceDoc._id) { + const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; + if (parentAncestry.includes(sourceDoc._id)) { + return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; } } }; -/* -Enforce the list of allowed parents for each contact type -Ensure we are not creating a circular hierarchy -*/ -const getMergeContactHierarchyViolations = (removedDoc, keptDoc) => { - const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); - const removedContactType = getContactType(removedDoc); - const keptContactType = getContactType(keptDoc); - if (!removedContactType) { - return 'contact required attribute "type" is undefined'; +const getCommonViolations = (sourceDoc, destinationDoc) => { + const sourceContactType = getContactType(sourceDoc); + const destinationContactType = getContactType(destinationDoc); + if (!sourceContactType) { + return `source contact "${sourceDoc._id}" required attribute "type" is undefined`; } - if (keptDoc && !keptContactType) { - return `kept contact "${keptDoc._id}" required attribute "type" is undefined`; + if (destinationDoc && !destinationContactType) { + return `destination contact "${destinationDoc._id}" required attribute "type" is undefined`; + } +}; + +const getMergeViolations = (sourceDoc, destinationDoc) => { + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + if (commonViolations) { + return commonViolations; } - if (removedContactType !== keptContactType) { - return `contact "${removedDoc._id}" must have same contact type as "${keptContactType}". Former is "${removedContactType}" while later is "${keptContactType}".`; + const sourceContactType = getContactType(sourceDoc); + const destinationContactType = getContactType(destinationDoc); + if (sourceContactType !== destinationContactType) { + return `source and destinations must have the same type. Source is "${sourceContactType}" while destination is "${destinationContactType}".`; } - if (removedDoc._id === keptDoc._id) { - return `Cannot merge contact with self`; + if (sourceDoc._id === destinationDoc._id) { + return `cannot move contact to destination that is itself`; } }; @@ -101,8 +111,8 @@ A place's primary contact must be a descendant of that place. 1. Check to see which part of the contact's lineage will be removed 2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed. */ -const getPrimaryContactViolations = async (repository, contactDoc, parentDoc, descendantDocs) => { - const safeGetLineageFromDoc = doc => doc ? pluckIdsFromLineage(doc.parent) : []; +const getPrimaryContactViolations = async (db, contactDoc, parentDoc, descendantDocs) => { + const safeGetLineageFromDoc = doc => doc ? lineageManipulation.pluckIdsFromLineage(doc.parent) : []; const contactsLineageIds = safeGetLineageFromDoc(contactDoc); const parentsLineageIds = safeGetLineageFromDoc(parentDoc); @@ -111,7 +121,7 @@ const getPrimaryContactViolations = async (repository, contactDoc, parentDoc, de } const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value)); - const docsRemovedFromContactLineage = await repository.allDocs({ + const docsRemovedFromContactLineage = await db.allDocs({ keys: docIdsRemovedFromContactLineage, include_docs: true, }); @@ -123,4 +133,31 @@ const getPrimaryContactViolations = async (repository, contactDoc, parentDoc, de return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); }; -module.exports = lineageConstraints; +const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); + +async function fetchAllowedParents(db) { + try { + const { settings } = await db.get('settings'); + const { contact_types } = settings; + + if (Array.isArray(contact_types)) { + trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.'); + return contact_types + .filter(rule => rule) + .reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {}); + } + } catch (err) { + if (err.name !== 'not_found') { + throw err; + } + } + + trace('Default hierarchy constraints will be enforced.'); + return { + district_hospital: [], + health_center: ['district_hospital'], + clinic: ['health_center'], + person: ['district_hospital', 'health_center', 'clinic'], + }; +} + diff --git a/src/lib/move-contacts/move-contacts-lib.js b/src/lib/move-contacts/move-contacts-lib.js index 5391d3459..00c0021a3 100644 --- a/src/lib/move-contacts/move-contacts-lib.js +++ b/src/lib/move-contacts/move-contacts-lib.js @@ -1,5 +1,5 @@ const lineageManipulation = require('./lineage-manipulation'); -const lineageConstraints = require('./lineage-constraints'); +const LineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); const Shared = require('./mm-shared'); @@ -8,11 +8,10 @@ module.exports = (options) => { const move = async (sourceIds, destinationId, db) => { Shared.prepareDocumentDirectory(options); trace(`Fetching contact details: ${destinationId}`); + const constraints = await LineageConstraints(db, options); const destinationDoc = await Shared.fetch.contact(db, destinationId); - - const constraints = await lineageConstraints(db, destinationDoc, options); const sourceDocs = await Shared.fetch.contactList(db, sourceIds); - await validateContacts(sourceDocs, constraints); + await constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); @@ -31,7 +30,7 @@ module.exports = (options) => { const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; // Check that primary contact is not removed from areas where they are required - const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(sourceDoc, descendantsAndSelf); + const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); if (invalidPrimaryContactDoc) { throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); } @@ -57,32 +56,6 @@ module.exports = (options) => { info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); }; - /* - 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 (sourceDocs, constraints) => { - Object.values(sourceDocs).forEach(doc => { - const hierarchyError = constraints.validate(doc); - if (hierarchyError) { - throw Error(`Hierarchy Constraints: ${hierarchyError}`); - } - }); - - /* - It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. - For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. - */ - const contactIds = Object.keys(sourceDocs); - Object.values(sourceDocs) - .forEach(doc => { - const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; - const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); - if (violatingParentId) { - throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); - } - }); - }; const moveReports = async (db, descendantsAndSelf, replacementLineage, sourceId, destinationId) => { const descendantIds = descendantsAndSelf.map(contact => contact._id); diff --git a/test/lib/move-contacts/lineage-constraints.spec.js b/test/lib/move-contacts/lineage-constraints.spec.js index 52e612c61..0e2d6d0ce 100644 --- a/test/lib/move-contacts/lineage-constraints.spec.js +++ b/test/lib/move-contacts/lineage-constraints.spec.js @@ -11,19 +11,13 @@ const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { - describe('getMoveContactHierarchyViolations', () => { - const scenario = async (contact_types, contactType, parentType) => { - const mockDb = { get: () => ({ settings: { contact_types } }) }; - const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: parentType }); - return getMoveContactHierarchyViolations({ type: contactType }); - }; + describe('getHierarchyErrors', () => { + it('empty rules yields error', async () => expect(await runScenario([], 'person', 'health_center')).to.include('unknown type')); - it('empty rules yields error', async () => expect(await scenario([], 'person', 'health_center')).to.include('unknown type')); - - it('no valid parent yields error', async () => expect(await scenario([undefined], 'person', 'health_center')).to.include('unknown type')); + it('no valid parent yields error', async () => expect(await runScenario([undefined], 'person', 'health_center')).to.include('unknown type')); it('valid parent yields no error', async () => { - const actual = await scenario([{ + const actual = await runScenario([{ id: 'person', parents: ['health_center'], }], 'person', 'health_center'); @@ -31,52 +25,56 @@ describe('lineage constriants', () => { expect(actual).to.be.undefined; }); - it('no contact type yields undefined error', async () => expect(await scenario([])).to.include('undefined')); + it('no contact type yields undefined error', async () => expect(await runScenario([])).to.include('undefined')); - it('no parent type yields undefined error', async () => expect(await scenario([], 'person')).to.include('undefined')); + it('no parent type yields undefined error', async () => expect(await runScenario([], 'person')).to.include('undefined')); - it('no valid parents yields not defined', async () => expect(await scenario([{ + it('no valid parents yields not defined', async () => expect(await runScenario([{ id: 'person', parents: ['district_hospital'], }], 'person', 'health_center')).to.include('cannot have parent of type')); it('no settings doc requires valid parent type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'dne' }); - const actual = getMoveContactHierarchyViolations({ type: 'person' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'person' }, { type: 'dne' }); expect(actual).to.include('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getMoveContactHierarchyViolations({ type: 'dne' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); expect(actual).to.include('unknown type'); }); it('no settings doc yields not defined', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getMoveContactHierarchyViolations({ type: 'person' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'person' }, { type: 'clinic' }); expect(actual).to.be.undefined; }); + it('cannot merge with self', async () => { + expect(await runScenario([], 'a', 'a', true)).to.include('self'); + }); + describe('default schema', () => { - it('no defined rules enforces defaults schema', async () => expect(await scenario(undefined, 'district_hospital', 'health_center')).to.include('cannot have parent')); + it('no defined rules enforces defaults schema', async () => expect(await runScenario(undefined, 'district_hospital', 'health_center')).to.include('cannot have parent')); - it('nominal case', async () => expect(await scenario(undefined, 'person', 'health_center')).to.be.undefined); + it('nominal case', async () => expect(await runScenario(undefined, 'person', 'health_center')).to.be.undefined); it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { getMoveContactHierarchyViolations } = await lineageConstraints(mockDb, undefined); - const actual = getMoveContactHierarchyViolations({ type: 'district_hospital' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'district_hospital' }, undefined); expect(actual).to.be.undefined; }); }); }); describe('getPrimaryContactViolations', () => { - const getMoveContactHierarchyViolations = lineageConstraints.__get__('getPrimaryContactViolations'); + const getHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); describe('on memory pouchdb', async () => { let pouchDb, scenarioCount = 0; @@ -106,13 +104,13 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_2'); - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); it('cannot move clinic_1_contact to root', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, undefined, [contactDoc]); + const doc = await getHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); @@ -120,7 +118,7 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_1'); - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); @@ -129,7 +127,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_1'); const descendants = await Promise.all(['health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2'].map(id => pouchDb.get(id))); - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, descendants); + const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.be.undefined; }); @@ -142,7 +140,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, descendants); + const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.deep.include({ _id: 'patient_1' }); }); @@ -153,9 +151,15 @@ describe('lineage constriants', () => { contactDoc.parent._id = 'dne'; - const doc = await getMoveContactHierarchyViolations(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); }); }); }); + +const runScenario = async (contact_types, sourceType, destinationType, merge = false) => { + const mockDb = { get: () => ({ settings: { contact_types } }) }; + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge }); + return getHierarchyErrors({ type: sourceType }, { type: destinationType }); +}; diff --git a/test/lib/move-contacts/merge-contacts.spec.js b/test/lib/move-contacts/merge-contacts.spec.js index 2d17eddcf..b4a7a74a5 100644 --- a/test/lib/move-contacts/merge-contacts.spec.js +++ b/test/lib/move-contacts/merge-contacts.spec.js @@ -223,6 +223,6 @@ describe('merge-contacts', () => { it('throw if removed is kept', async () => { const actual = move(['district_1', 'district_2'], 'district_2', pouchDb); - await expect(actual).to.eventually.rejectedWith('merge contact with self'); + await expect(actual).to.eventually.rejectedWith('that is itself'); }); }); From 5ad9d854d2ac38f49000beebf8862e06f029450d Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 17:20:32 -0700 Subject: [PATCH 10/66] Rename to Hierarchy Operations --- src/fn/merge-contacts.js | 4 ++-- src/fn/move-contacts.js | 4 ++-- .../index.js} | 13 ++++++++++++- .../lineage-constraints.js | 0 .../lineage-manipulation.js | 0 .../mm-shared.js | 12 ------------ .../lineage-constraints.spec.js | 2 +- .../lineage-manipulation.spec.js | 2 +- .../merge-contacts.spec.js | 8 ++++---- .../mm-shared.spec.js | 2 +- .../move-contacts.spec.js | 8 ++++---- 11 files changed, 27 insertions(+), 28 deletions(-) rename src/lib/{move-contacts/move-contacts-lib.js => hierarchy-operations/index.js} (91%) rename src/lib/{move-contacts => hierarchy-operations}/lineage-constraints.js (100%) rename src/lib/{move-contacts => hierarchy-operations}/lineage-manipulation.js (100%) rename src/lib/{move-contacts => hierarchy-operations}/mm-shared.js (89%) rename test/lib/{move-contacts => hierarchy-operations}/lineage-constraints.spec.js (98%) rename test/lib/{move-contacts => hierarchy-operations}/lineage-manipulation.spec.js (98%) rename test/lib/{move-contacts => hierarchy-operations}/merge-contacts.spec.js (96%) rename test/lib/{move-contacts => hierarchy-operations}/mm-shared.spec.js (95%) rename test/lib/{move-contacts => hierarchy-operations}/move-contacts.spec.js (98%) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 13c3e9a19..88575bf69 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -5,7 +5,7 @@ const environment = require('../lib/environment'); const pouch = require('../lib/db'); const { info } = require('../lib/log'); -const MoveContactsLib = require('../lib/move-contacts/move-contacts-lib'); +const HierarchyOperations = require('../lib/hierarchy-operations'); module.exports = { requiresInstance: true, @@ -17,7 +17,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return MoveContactsLib(options).move(args.removeIds, args.keepId, db); + return HierarchyOperations(options).move(args.removeIds, args.keepId, db); } }; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 7e1e68a56..2d97b1137 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -5,7 +5,7 @@ const environment = require('../lib/environment'); const pouch = require('../lib/db'); const { info } = require('../lib/log'); -const MoveContactsLib = require('../lib/move-contacts/move-contacts-lib'); +const HierarchyOperations = require('../lib/hierarchy-operations'); module.exports = { requiresInstance: true, @@ -17,7 +17,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return MoveContactsLib(options).move(args.contactIds, args.parentId, db); + return HierarchyOperations(options).move(args.contactIds, args.parentId, db); } }; diff --git a/src/lib/move-contacts/move-contacts-lib.js b/src/lib/hierarchy-operations/index.js similarity index 91% rename from src/lib/move-contacts/move-contacts-lib.js rename to src/lib/hierarchy-operations/index.js index 00c0021a3..c6c7dca15 100644 --- a/src/lib/move-contacts/move-contacts-lib.js +++ b/src/lib/hierarchy-operations/index.js @@ -40,7 +40,7 @@ module.exports = (options) => { const ancestors = await Shared.fetch.ancestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedAncestors = Shared.replaceLineageInAncestors(descendantsAndSelf, ancestors); + const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors]); @@ -117,6 +117,17 @@ module.exports = (options) => { return agg; }, []); + const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { + let result = agg; + const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + if (primaryContact) { + ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); + result = [ancestor, ...result]; + } + + return result; + }, []); + const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, destinationId) => descendantsAndSelf.reduce((agg, doc) => { const startingFromIdInLineage = options.merge ? destinationId : doc._id === destinationId ? undefined : destinationId; diff --git a/src/lib/move-contacts/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js similarity index 100% rename from src/lib/move-contacts/lineage-constraints.js rename to src/lib/hierarchy-operations/lineage-constraints.js diff --git a/src/lib/move-contacts/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js similarity index 100% rename from src/lib/move-contacts/lineage-manipulation.js rename to src/lib/hierarchy-operations/lineage-manipulation.js diff --git a/src/lib/move-contacts/mm-shared.js b/src/lib/hierarchy-operations/mm-shared.js similarity index 89% rename from src/lib/move-contacts/mm-shared.js rename to src/lib/hierarchy-operations/mm-shared.js index 37f6f7fc7..441d57369 100644 --- a/src/lib/move-contacts/mm-shared.js +++ b/src/lib/hierarchy-operations/mm-shared.js @@ -32,17 +32,6 @@ const writeDocumentToDisk = ({ docDirectoryPath }, doc) => { fs.writeJson(destinationPath, doc); }; -const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); - if (primaryContact) { - ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; - } - - return result; -}, []); - const fetch = { /* @@ -135,7 +124,6 @@ module.exports = { HIERARCHY_ROOT, BATCH_SIZE, prepareDocumentDirectory, - replaceLineageInAncestors, writeDocumentToDisk, fetch, }; diff --git a/test/lib/move-contacts/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js similarity index 98% rename from test/lib/move-contacts/lineage-constraints.spec.js rename to test/lib/hierarchy-operations/lineage-constraints.spec.js index 0e2d6d0ce..d4812d115 100644 --- a/test/lib/move-contacts/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -6,7 +6,7 @@ PouchDB.plugin(require('pouchdb-mapreduce')); const { mockHierarchy } = require('../../mock-hierarchies'); -const lineageConstraints = rewire('../../../src/lib/move-contacts/lineage-constraints'); +const lineageConstraints = rewire('../../../src/lib/hierarchy-operations/lineage-constraints'); const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; diff --git a/test/lib/move-contacts/lineage-manipulation.spec.js b/test/lib/hierarchy-operations/lineage-manipulation.spec.js similarity index 98% rename from test/lib/move-contacts/lineage-manipulation.spec.js rename to test/lib/hierarchy-operations/lineage-manipulation.spec.js index 1a4fc467a..f73bf3e4e 100644 --- a/test/lib/move-contacts/lineage-manipulation.spec.js +++ b/test/lib/hierarchy-operations/lineage-manipulation.spec.js @@ -1,5 +1,5 @@ const { expect } = require('chai'); -const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/move-contacts/lineage-manipulation'); +const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; diff --git a/test/lib/move-contacts/merge-contacts.spec.js b/test/lib/hierarchy-operations/merge-contacts.spec.js similarity index 96% rename from test/lib/move-contacts/merge-contacts.spec.js rename to test/lib/hierarchy-operations/merge-contacts.spec.js index b4a7a74a5..120b6b602 100644 --- a/test/lib/move-contacts/merge-contacts.spec.js +++ b/test/lib/hierarchy-operations/merge-contacts.spec.js @@ -3,7 +3,7 @@ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const rewire = require('rewire'); -const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); +const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); chai.use(chaiAsPromised); const { expect } = chai; @@ -12,10 +12,10 @@ const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const MoveContactsLib = rewire('../../../src/lib/move-contacts/move-contacts-lib'); -MoveContactsLib.__set__('Shared', Shared); +const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); +HierarchyOperations.__set__('Shared', Shared); -const move = MoveContactsLib({ merge: true }).move; +const move = HierarchyOperations({ merge: true }).move; const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); diff --git a/test/lib/move-contacts/mm-shared.spec.js b/test/lib/hierarchy-operations/mm-shared.spec.js similarity index 95% rename from test/lib/move-contacts/mm-shared.spec.js rename to test/lib/hierarchy-operations/mm-shared.spec.js index 30cb03b61..1d686ef17 100644 --- a/test/lib/move-contacts/mm-shared.spec.js +++ b/test/lib/hierarchy-operations/mm-shared.spec.js @@ -4,7 +4,7 @@ const sinon = require('sinon'); const environment = require('../../../src/lib/environment'); const fs = require('../../../src/lib/sync-fs'); -const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); +const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); const userPrompt = rewire('../../../src/lib/user-prompt'); diff --git a/test/lib/move-contacts/move-contacts.spec.js b/test/lib/hierarchy-operations/move-contacts.spec.js similarity index 98% rename from test/lib/move-contacts/move-contacts.spec.js rename to test/lib/hierarchy-operations/move-contacts.spec.js index a15a4170a..4788ce316 100644 --- a/test/lib/move-contacts/move-contacts.spec.js +++ b/test/lib/hierarchy-operations/move-contacts.spec.js @@ -3,16 +3,16 @@ const rewire = require('rewire'); const sinon = require('sinon'); const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); -const Shared = rewire('../../../src/lib/move-contacts/mm-shared'); +const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const MoveContactsLib = rewire('../../../src/lib/move-contacts/move-contacts-lib'); -MoveContactsLib.__set__('Shared', Shared); +const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); +HierarchyOperations.__set__('Shared', Shared); -const move = MoveContactsLib({ merge: false }).move; +const move = HierarchyOperations({ merge: false }).move; const contacts_by_depth = { // eslint-disable-next-line quotes From 7ea3393f3e1890e6928a98f4e1a39001fae78077 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 21 Nov 2024 17:48:09 -0700 Subject: [PATCH 11/66] replaceRelevantLineage --- src/lib/hierarchy-operations/index.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index c6c7dca15..7873ca2bf 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -109,9 +109,16 @@ module.exports = (options) => { }); }; + const replaceRelevantLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { + if (options?.merge) { + return lineageManipulation.replaceLineageAt(doc, lineageAttributeName, replaceWith, startingFromIdInLineage); + } + + return lineageManipulation.replaceLineageAfter(doc, lineageAttributeName, replaceWith, startingFromIdInLineage); + }; + const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - const operation = options.merge ? lineageManipulation.replaceLineageAt : lineageManipulation.replaceLineageAfter; - if (operation(doc, 'contact', replaceWith, startingFromIdInLineage)) { + if (replaceRelevantLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) { agg.push(doc); } return agg; @@ -139,9 +146,8 @@ module.exports = (options) => { } } - const lineageOperation = options.merge ? lineageManipulation.replaceLineageAt : lineageManipulation.replaceLineageAfter; - const parentWasUpdated = lineageOperation(doc, 'parent', replacementLineage, startingFromIdInLineage); - const contactWasUpdated = lineageOperation(doc, 'contact', replacementLineage, destinationId); + const parentWasUpdated = replaceRelevantLineage(doc, 'parent', replacementLineage, startingFromIdInLineage); + const contactWasUpdated = replaceRelevantLineage(doc, 'contact', replacementLineage, destinationId); if (parentWasUpdated || contactWasUpdated) { agg.push(doc); } From 78f2c0178bd7afbb038d0818939f531863112303 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 18:30:44 -0700 Subject: [PATCH 12/66] Refacatoring for lineage-manipulation --- src/lib/hierarchy-operations/index.js | 14 +---- .../lineage-manipulation.js | 63 +++++++++---------- .../lineage-manipulation.spec.js | 39 +++++++++--- 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 7873ca2bf..4a382236e 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -109,16 +109,8 @@ module.exports = (options) => { }); }; - const replaceRelevantLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { - if (options?.merge) { - return lineageManipulation.replaceLineageAt(doc, lineageAttributeName, replaceWith, startingFromIdInLineage); - } - - return lineageManipulation.replaceLineageAfter(doc, lineageAttributeName, replaceWith, startingFromIdInLineage); - }; - const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (replaceRelevantLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) { + if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage, options)) { agg.push(doc); } return agg; @@ -146,8 +138,8 @@ module.exports = (options) => { } } - const parentWasUpdated = replaceRelevantLineage(doc, 'parent', replacementLineage, startingFromIdInLineage); - const contactWasUpdated = replaceRelevantLineage(doc, 'contact', replacementLineage, destinationId); + const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); + const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); if (parentWasUpdated || contactWasUpdated) { agg.push(doc); } diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 001c637dc..ebd7df97f 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -1,49 +1,49 @@ -/* -Given a doc, replace the lineage information therein with "replaceWith" - -startingFromIdInLineage (optional) - Will result in a partial replacement of the lineage. Only the part of the lineage "after" the parent -with _id=startingFromIdInLineage will be replaced by "replaceWith" -*/ -const replaceLineageAfter = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { +/** + * Given a doc, replace the lineage information therein with "replaceWith" + * + * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {string} lineageAttributeName Name of the attribute which is a lineage in doc (contact or parent) + * @param {Object} replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} [startingFromIdInLineage] Only the part of the lineage "after" this id will be replaced + * @param {Object} options + * @param {boolean} merge When true, startingFromIdInLineage is replaced and when false, startingFromIdInLineage's parent is replaced + */ +const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage, options={}) => { // Replace the full lineage if (!startingFromIdInLineage) { - return _doReplaceInLineage(doc, lineageAttributeName, replaceWith); + return replaceWithinLineage(doc, lineageAttributeName, replaceWith); } - // Replace part of a lineage - let currentParent = doc[lineageAttributeName]; - while (currentParent) { - if (currentParent._id === startingFromIdInLineage) { - return _doReplaceInLineage(currentParent, 'parent', replaceWith); + const initialState = () => { + if (options.merge) { + return { + element: doc, + attributeName: lineageAttributeName, + }; } - currentParent = currentParent.parent; - } - return false; -}; - -const replaceLineageAt = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { - if (!replaceWith || !startingFromIdInLineage) { - throw Error('replaceWith and startingFromIdInLineage must be defined'); + return { + element: doc[lineageAttributeName], + attributeName: 'parent', + }; } - // Replace part of a lineage - let currentElement = doc; - let currentAttributeName = lineageAttributeName; - while (currentElement) { - if (currentElement[currentAttributeName]?._id === startingFromIdInLineage) { - return _doReplaceInLineage(currentElement, currentAttributeName, replaceWith); + const state = initialState(); + while (state.element) { + const compare = options.merge ? state.element[state.attributeName] : state.element; + if (compare?._id === startingFromIdInLineage) { + return replaceWithinLineage(state.element, state.attributeName, replaceWith); } - currentElement = currentElement[currentAttributeName]; - currentAttributeName = 'parent'; + state.element = state.element[state.attributeName]; + state.attributeName = 'parent'; } return false; }; -const _doReplaceInLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { +const replaceWithinLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { if (!replaceWith) { const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; replaceInDoc[lineageAttributeName] = undefined; @@ -123,6 +123,5 @@ module.exports = { createLineageFromDoc, minifyLineagesInDoc, pluckIdsFromLineage, - replaceLineageAfter, - replaceLineageAt, + replaceLineage, }; diff --git a/test/lib/hierarchy-operations/lineage-manipulation.spec.js b/test/lib/hierarchy-operations/lineage-manipulation.spec.js index f73bf3e4e..be324009e 100644 --- a/test/lib/hierarchy-operations/lineage-manipulation.spec.js +++ b/test/lib/hierarchy-operations/lineage-manipulation.spec.js @@ -1,18 +1,19 @@ const { expect } = require('chai'); -const { replaceLineageAfter, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); +const { replaceLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; const { parentsToLineage } = require('../../mock-hierarchies'); +const mergeOption = { merge: true }; describe('lineage manipulation', () => { - describe('replaceLineageAfter', () => { + describe('kenn replaceLineage', () => { const mockReport = data => Object.assign({ _id: 'r', type: 'data_record', contact: parentsToLineage('parent', 'grandparent') }, data); const mockContact = data => Object.assign({ _id: 'c', type: 'person', parent: parentsToLineage('parent', 'grandparent') }, data); it('replace with empty lineage', () => { const mock = mockReport(); - expect(replaceLineageAfter(mock, 'contact', undefined)).to.be.true; + expect(replaceLineage(mock, 'contact', undefined)).to.be.true; expect(mock).to.deep.eq({ _id: 'r', type: 'data_record', @@ -22,7 +23,7 @@ describe('lineage manipulation', () => { it('replace full lineage', () => { const mock = mockContact(); - expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -34,7 +35,7 @@ describe('lineage manipulation', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -45,12 +46,12 @@ describe('lineage manipulation', () => { it('replace empty with empty', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineageAfter(mock, 'parent', undefined)).to.be.false; + expect(replaceLineage(mock, 'parent', undefined)).to.be.false; }); it('replace lineage starting at contact', () => { const mock = mockContact(); - expect(replaceLineageAfter(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; + expect(replaceLineage(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -58,9 +59,29 @@ describe('lineage manipulation', () => { }); }); + it('merge new parent', () => { + const mock = mockContact(); + expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent', 'new_grandparent'), 'parent', mergeOption)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'c', + type: 'person', + parent: parentsToLineage('new_parent', 'new_grandparent'), + }); + }); + + it('merge grandparent of contact', () => { + const mock = mockReport(); + expect(replaceLineage(mock, 'contact', parentsToLineage('new_grandparent'), 'grandparent', mergeOption)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'r', + type: 'data_record', + contact: parentsToLineage('parent', 'new_grandparent'), + }); + }); + it('replace empty starting at contact', () => { const mock = mockContact(); - expect(replaceLineageAfter(mock, 'parent', undefined, 'parent')).to.be.true; + expect(replaceLineage(mock, 'parent', undefined, 'parent')).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -70,7 +91,7 @@ describe('lineage manipulation', () => { it('replace starting at non-existant contact', () => { const mock = mockContact(); - expect(replaceLineageAfter(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; + expect(replaceLineage(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; }); }); From d677b487c96649607b11ff46ff7b658a8049622a Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 19:14:04 -0700 Subject: [PATCH 13/66] Tests for fn folder --- src/fn/merge-contacts.js | 11 +- src/fn/move-contacts.js | 13 +- .../{mm-shared.js => backend.js} | 31 -- src/lib/hierarchy-operations/index.js | 30 +- src/lib/hierarchy-operations/jsdocFolder.js | 32 +++ test/fn/merge-contacts.spec.js | 26 ++ test/fn/move-contacts.spec.js | 27 ++ ...s.spec.js => hierarchy-operations.spec.js} | 265 ++++++++++++------ .../{mm-shared.spec.js => jsdocs.spec.js} | 13 +- .../merge-contacts.spec.js | 228 --------------- 10 files changed, 298 insertions(+), 378 deletions(-) rename src/lib/hierarchy-operations/{mm-shared.js => backend.js} (71%) create mode 100644 src/lib/hierarchy-operations/jsdocFolder.js create mode 100644 test/fn/merge-contacts.spec.js create mode 100644 test/fn/move-contacts.spec.js rename test/lib/hierarchy-operations/{move-contacts.spec.js => hierarchy-operations.spec.js} (76%) rename test/lib/hierarchy-operations/{mm-shared.spec.js => jsdocs.spec.js} (82%) delete mode 100644 test/lib/hierarchy-operations/merge-contacts.spec.js diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 88575bf69..6ad0edb98 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -13,11 +13,10 @@ module.exports = { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); const options = { - merge: true, docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options).move(args.removeIds, args.keepId, db); + return HierarchyOperations(options).merge(args.sourceIds, args.destinationId, db); } }; @@ -25,7 +24,7 @@ module.exports = { const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const removeIds = (args.remove || '') + const sourceIds = (args.remove || '') .split(',') .filter(Boolean); @@ -34,14 +33,14 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--keep')}. Other contacts will be merged into this contact.`); } - if (removeIds.length === 0) { + if (sourceIds.length === 0) { usage(); throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--remove')}. These contacts will be merged into the contact specified by ${bold('--keep')}`); } return { - keepId: args.keep, - removeIds, + destinationId: args.keep, + sourceIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 2d97b1137..75de128dc 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -13,11 +13,10 @@ module.exports = { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); const options = { - merge: false, docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options).move(args.contactIds, args.parentId, db); + return HierarchyOperations(options).move(args.sourceIds, args.destinationId, db); } }; @@ -25,11 +24,11 @@ module.exports = { const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const contactIds = (args.contacts || args.contact || '') + const sourceIds = (args.contacts || args.contact || '') .split(',') .filter(id => id); - if (contactIds.length === 0) { + if (sourceIds.length === 0) { usage(); throw Error('Action "move-contacts" is missing required list of contacts to be moved'); } @@ -40,8 +39,8 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { } return { - parentId: args.parent, - contactIds, + destinationId: args.parent, + sourceIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; @@ -61,7 +60,7 @@ ${bold('OPTIONS')} A comma delimited list of ids of contacts to be moved. --parent= - Specifies the ID of the new parent. Use '${Shared.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). + Specifies the ID of the new parent. Use '${HierarchyOperations.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. diff --git a/src/lib/hierarchy-operations/mm-shared.js b/src/lib/hierarchy-operations/backend.js similarity index 71% rename from src/lib/hierarchy-operations/mm-shared.js rename to src/lib/hierarchy-operations/backend.js index 441d57369..dd794c125 100644 --- a/src/lib/hierarchy-operations/mm-shared.js +++ b/src/lib/hierarchy-operations/backend.js @@ -1,38 +1,9 @@ const _ = require('lodash'); -const path = require('path'); - -const userPrompt = require('../user-prompt'); -const fs = require('../sync-fs'); -const { warn, trace } = require('../log'); const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; const BATCH_SIZE = 10000; -const prepareDocumentDirectory = ({ docDirectoryPath, force }) => { - if (!fs.exists(docDirectoryPath)) { - fs.mkdir(docDirectoryPath); - } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { - warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); - if(userPrompt.keyInYN()) { - fs.deleteFilesInFolder(docDirectoryPath); - } else { - throw new Error('User aborted execution.'); - } - } -}; - -const writeDocumentToDisk = ({ docDirectoryPath }, doc) => { - const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); - if (fs.exists(destinationPath)) { - warn(`File at ${destinationPath} already exists and is being overwritten.`); - } - - trace(`Writing updated document to ${destinationPath}`); - fs.writeJson(destinationPath, doc); -}; - - const fetch = { /* Fetches all of the documents associated with the "contactIds" and confirms they exist. @@ -123,7 +94,5 @@ const fetch = { module.exports = { HIERARCHY_ROOT, BATCH_SIZE, - prepareDocumentDirectory, - writeDocumentToDisk, fetch, }; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 4a382236e..5b72875ba 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -2,26 +2,27 @@ const lineageManipulation = require('./lineage-manipulation'); const LineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); -const Shared = require('./mm-shared'); +const JsDocs = require('./jsdocFolder'); +const Backend = require('./backend'); -module.exports = (options) => { +const HierarchyOperations = (options) => { const move = async (sourceIds, destinationId, db) => { - Shared.prepareDocumentDirectory(options); + JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); - const destinationDoc = await Shared.fetch.contact(db, destinationId); - const sourceDocs = await Shared.fetch.contactList(db, sourceIds); + const destinationDoc = await Backend.fetch.contact(db, destinationId); + const sourceDocs = await Backend.fetch.contactList(db, sourceIds); await constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); for (let sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; - const descendantsAndSelf = await Shared.fetch.descendantsOf(db, sourceId); + const descendantsAndSelf = await Backend.fetch.descendantsOf(db, sourceId); if (options.merge) { const self = descendantsAndSelf.find(d => d._id === sourceId); - Shared.writeDocumentToDisk(options, { + JsDocs.writeDoc(options, { _id: self._id, _rev: self._rev, _deleted: true, @@ -38,7 +39,7 @@ module.exports = (options) => { trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, sourceId); - const ancestors = await Shared.fetch.ancestorsOf(db, sourceDoc); + const ancestors = await Backend.fetch.ancestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); @@ -63,9 +64,9 @@ module.exports = (options) => { let skip = 0; let reportDocsBatch; do { - info(`Processing ${skip} to ${skip + Shared.BATCH_SIZE} report docs`); + info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); const createdAtId = options.merge && sourceId; - reportDocsBatch = await Shared.fetch.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + reportDocsBatch = await Backend.fetch.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); @@ -97,7 +98,7 @@ module.exports = (options) => { minifyLineageAndWriteToDisk(updatedReports); skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= Shared.BATCH_SIZE); + } while (reportDocsBatch.length >= Backend.BATCH_SIZE); return skip; }; @@ -105,7 +106,7 @@ module.exports = (options) => { const minifyLineageAndWriteToDisk = (docs) => { docs.forEach(doc => { lineageManipulation.minifyLineagesInDoc(doc); - Shared.writeDocumentToDisk(options, doc); + JsDocs.writeDoc(options, doc); }); }; @@ -149,3 +150,8 @@ module.exports = (options) => { return { move }; }; +module.exports = options => ({ + HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, + move: HierarchyOperations({ ...options, merge: false }).move, + merge: HierarchyOperations({ ...options, merge: true }).move, +}); diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js new file mode 100644 index 000000000..fd46ca5cf --- /dev/null +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -0,0 +1,32 @@ +const path = require('path'); +const userPrompt = require('../user-prompt'); +const fs = require('../sync-fs'); +const { warn, trace } = require('../log'); + +const prepareFolder = ({ docDirectoryPath, force }) => { + if (!fs.exists(docDirectoryPath)) { + fs.mkdir(docDirectoryPath); + } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { + warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); + if(userPrompt.keyInYN()) { + fs.deleteFilesInFolder(docDirectoryPath); + } else { + throw new Error('User aborted execution.'); + } + } +}; + +const writeDoc = ({ docDirectoryPath }, doc) => { + const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); + if (fs.exists(destinationPath)) { + warn(`File at ${destinationPath} already exists and is being overwritten.`); + } + + trace(`Writing updated document to ${destinationPath}`); + fs.writeJson(destinationPath, doc); +}; + +module.exports = { + prepareFolder, + writeDoc, +}; diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js new file mode 100644 index 000000000..c4f519ad5 --- /dev/null +++ b/test/fn/merge-contacts.spec.js @@ -0,0 +1,26 @@ +const { expect } = require('chai'); +const rewire = require('rewire'); +const Mergeremove = rewire('../../src/fn/merge-contacts'); +const parseExtraArgs = Mergeremove.__get__('parseExtraArgs'); + +describe('merge-contacts', () => { + describe('parseExtraArgs', () => { + it('undefined arguments', () => { + expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required contact'); + }); + + it('empty arguments', () => expect(() => parseExtraArgs(__dirname, [])).to.throw('required contact')); + + it('remove only', () => expect(() => parseExtraArgs(__dirname, ['--remove=a'])).to.throw('required contact')); + + it('remove and keeps', () => { + const args = ['--remove=food,is,tasty', '--keep=bar', '--docDirectoryPath=/', '--force=hi']; + expect(parseExtraArgs(__dirname, args)).to.deep.eq({ + sourceIds: ['food', 'is', 'tasty'], + destinationId: 'bar', + force: true, + docDirectoryPath: '/', + }); + }); + }); +}); diff --git a/test/fn/move-contacts.spec.js b/test/fn/move-contacts.spec.js new file mode 100644 index 000000000..60068c13b --- /dev/null +++ b/test/fn/move-contacts.spec.js @@ -0,0 +1,27 @@ +const { expect } = require('chai'); +const rewire = require('rewire'); +const MoveContacts = rewire('../../src/fn/move-contacts'); +const parseExtraArgs = MoveContacts.__get__('parseExtraArgs'); + +describe('move-contacts', () => { + describe('parseExtraArgs', () => { + // const parseExtraArgs = MoveContactsLib.__get__('parseExtraArgs'); + it('undefined arguments', () => { + expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required list of contacts'); + }); + + it('empty arguments', () => expect(() => parseExtraArgs(__dirname, [])).to.throw('required list of contacts')); + + it('contacts only', () => expect(() => parseExtraArgs(__dirname, ['--contacts=a'])).to.throw('required parameter parent')); + + it('contacts and parents', () => { + const args = ['--contacts=food,is,tasty', '--parent=bar', '--docDirectoryPath=/', '--force=hi']; + expect(parseExtraArgs(__dirname, args)).to.deep.eq({ + sourceIds: ['food', 'is', 'tasty'], + destinationId: 'bar', + force: true, + docDirectoryPath: '/', + }); + }); + }); +}); diff --git a/test/lib/hierarchy-operations/move-contacts.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js similarity index 76% rename from test/lib/hierarchy-operations/move-contacts.spec.js rename to test/lib/hierarchy-operations/hierarchy-operations.spec.js index 4788ce316..525cb00cc 100644 --- a/test/lib/hierarchy-operations/move-contacts.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -1,18 +1,23 @@ -const { assert, expect } = require('chai'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); const rewire = require('rewire'); const sinon = require('sinon'); const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); -const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); +const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder.js'); +const Backend = rewire('../../../src/lib/hierarchy-operations/backend.js'); const PouchDB = require('pouchdb-core'); + +chai.use(chaiAsPromised); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); -HierarchyOperations.__set__('Shared', Shared); +const { assert, expect } = chai; -const move = HierarchyOperations({ merge: false }).move; +const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); +HierarchyOperations.__set__('JsDocs', JsDocs); +HierarchyOperations.__set__('Backend', Backend); const contacts_by_depth = { // eslint-disable-next-line quotes @@ -25,7 +30,6 @@ const reports_by_freetext = { }; describe('move-contacts', () => { - let pouchDb, scenarioCount = 0; const writtenDocs = []; const getWrittenDoc = docId => { @@ -39,7 +43,7 @@ describe('move-contacts', () => { delete result._rev; return result; }; - const expectWrittenDocs = expected => expect(writtenDocs.map(doc => doc._id)).to.deep.eq(expected); + const expectWrittenDocs = expected => expect(writtenDocs.map(doc => doc._id)).to.have.members(expected); const upsert = async (id, content) => { const { _rev } = await pouchDb.get(id); @@ -61,7 +65,13 @@ describe('move-contacts', () => { }, }, }, - district_2: {}, + district_2: { + health_center_2: { + clinic_2: { + patient_2: {}, + } + } + }, }); await pouchDb.put({ _id: 'settings', settings: {} }); @@ -81,15 +91,15 @@ describe('move-contacts', () => { views: { contacts_by_depth }, }); - Shared.writeDocumentToDisk = (docDirectoryPath, doc) => writtenDocs.push(doc); - Shared.prepareDocumentDirectory = () => {}; + JsDocs.writeDoc = (docDirectoryPath, doc) => writtenDocs.push(doc); + JsDocs.prepareFolder = () => {}; writtenDocs.length = 0; }); afterEach(async () => pouchDb.destroy()); - + it('move health_center_1 to district_2', async () => { - await move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -131,7 +141,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: [] }]); - await move(['health_center_1'], 'root', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'root', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -184,7 +194,7 @@ describe('move-contacts', () => { it('move district_1 from root', async () => { await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - await move(['district_1'], 'district_2', pouchDb); + await HierarchyOperations().move(['district_1'], 'district_2', pouchDb); expect(getWrittenDoc('district_1')).to.deep.eq({ _id: 'district_1', @@ -240,7 +250,7 @@ describe('move-contacts', () => { { id: 'district_hospital', parents: ['county'] }, ]); - await move(['district_1'], 'county_1', pouchDb); + await HierarchyOperations().move(['district_1'], 'county_1', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -295,7 +305,7 @@ describe('move-contacts', () => { creatorId: 'focal', }); - await move(['focal'], 'subcounty', pouchDb); + await HierarchyOperations().move(['focal'], 'subcounty', pouchDb); expect(getWrittenDoc('focal')).to.deep.eq({ _id: 'focal', @@ -340,7 +350,7 @@ describe('move-contacts', () => { parent: parentsToLineage(), }); - await move(['t_patient_1'], 't_clinic_2', pouchDb); + await HierarchyOperations().move(['t_patient_1'], 't_clinic_2', pouchDb); expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ _id: 't_health_center_1', @@ -361,7 +371,7 @@ describe('move-contacts', () => { // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' it('district_hospital with empty string parent is not preserved', async () => { await upsert('district_2', { parent: '', type: 'district_hospital' }); - await move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); expect(getWrittenDoc('health_center_1')).to.deep.eq({ _id: 'health_center_1', @@ -371,6 +381,133 @@ describe('move-contacts', () => { }); }); + describe('merging', () => { + it('merge district_2 into district_1', async () => { + // setup + await mockReport(pouchDb, { + id: 'changing_subject_and_contact', + creatorId: 'health_center_2_contact', + patientId: 'district_2' + }); + + await mockReport(pouchDb, { + id: 'changing_contact', + creatorId: 'health_center_2_contact', + patientId: 'patient_2' + }); + + await mockReport(pouchDb, { + id: 'changing_subject', + patientId: 'district_2' + }); + + // action + await HierarchyOperations().merge(['district_2'], '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, + }); + + expect(getWrittenDoc('health_center_2')).to.deep.eq({ + _id: 'health_center_2', + type: 'health_center', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + parent: parentsToLineage('district_1'), + }); + + expect(getWrittenDoc('clinic_2')).to.deep.eq({ + _id: 'clinic_2', + type: 'clinic', + contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_1'), + parent: parentsToLineage('health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('patient_2')).to.deep.eq({ + _id: 'patient_2', + type: 'person', + parent: parentsToLineage('clinic_2', 'health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('changing_subject_and_contact')).to.deep.eq({ + _id: 'changing_subject_and_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'district_1' + } + }); + + expect(getWrittenDoc('changing_contact')).to.deep.eq({ + _id: 'changing_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'patient_2' + } + }); + + expect(getWrittenDoc('changing_subject')).to.deep.eq({ + _id: 'changing_subject', + form: 'foo', + type: 'data_record', + contact: { + _id: 'dne', + }, + fields: { + patient_uuid: 'district_1' + } + }); + }); + + 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 HierarchyOperations().merge(['patient_2'], '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' + } + }); + }); + }); + it('documents should be minified', async () => { await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); const patient = { @@ -389,7 +526,7 @@ describe('move-contacts', () => { await upsert('clinic_1', clinic); await upsert('patient_1', patient); - await move(['clinic_1'], 'district_2', pouchDb); + await HierarchyOperations().move(['clinic_1'], 'district_2', pouchDb); expect(getWrittenDoc('clinic_1')).to.deep.eq({ _id: 'clinic_1', @@ -410,7 +547,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); try { - await move(['health_center_1'], 'clinic_1', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'clinic_1', pouchDb); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('circular'); @@ -418,85 +555,39 @@ describe('move-contacts', () => { }); it('throw if parent does not exist', async () => { - try { - await move(['clinic_1'], 'dne_parent_id', pouchDb); - assert.fail('should throw when parent is not defined'); - } catch (err) { - expect(err.message).to.include('could not be found'); - } + const actual = HierarchyOperations().move(['clinic_1'], 'dne_parent_id', pouchDb); + await expect(actual).to.eventually.rejectedWith('could not be found'); }); it('throw when altering same lineage', async () => { - try { - await move(['patient_1', 'health_center_1'], 'district_2', pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('same lineage'); - } + const actual = HierarchyOperations().move(['patient_1', 'health_center_1'], 'district_2', pouchDb); + await expect(actual).to.eventually.rejectedWith('same lineage'); }); it('throw if contact_id is not a contact', async () => { - try { - await move(['report_1'], 'clinic_1', pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('unknown type'); - } + const actual = HierarchyOperations().move(['report_1'], 'clinic_1', pouchDb); + await expect(actual).to.eventually.rejectedWith('unknown type'); }); it('throw if moving primary contact of parent', async () => { - try { - await move(['clinic_1_contact'], 'district_1', pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('primary contact'); - } + const actual = HierarchyOperations().move(['clinic_1_contact'], 'district_1', pouchDb); + await expect(actual).to.eventually.rejectedWith('primary contact'); }); it('throw if setting parent to self', async () => { await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); - try { - await move(['clinic_1'], 'clinic_1', pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('circular'); - } + const actual = HierarchyOperations().move(['clinic_1'], 'clinic_1', pouchDb); + await expect(actual).to.eventually.rejectedWith('circular'); }); it('throw when moving place to unconfigured parent', async () => { await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); - - try { - await move(['district_1'], 'district_2', pouchDb); - assert.fail('Expected error'); - } catch (err) { - expect(err.message).to.include('parent of type'); - } - }); - - xdescribe('parseExtraArgs', () => { - // const parseExtraArgs = MoveContactsLib.__get__('parseExtraArgs'); - it('undefined arguments', () => { - expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required list of contacts'); - }); - - it('empty arguments', () => expect(() => parseExtraArgs(__dirname, [])).to.throw('required list of contacts')); - - it('contacts only', () => expect(() => parseExtraArgs(__dirname, ['--contacts=a'])).to.throw('required parameter parent')); - - it('contacts and parents', () => { - const args = ['--contacts=food,is,tasty', '--parent=bar', '--docDirectoryPath=/', '--force=hi']; - expect(parseExtraArgs(__dirname, args)).to.deep.eq({ - sourceIds: ['food', 'is', 'tasty'], - destinationId: 'bar', - force: true, - docDirectoryPath: '/', - }); - }); + const actual = HierarchyOperations().move(['district_1'], 'district_2', pouchDb); + await expect(actual).to.eventually.rejectedWith('parent of type'); }); describe('batching works as expected', () => { - const initialBatchSize = Shared.BATCH_SIZE; + const initialBatchSize = Backend.BATCH_SIZE; beforeEach(async () => { await mockReport(pouchDb, { id: 'report_2', @@ -515,16 +606,16 @@ describe('move-contacts', () => { }); afterEach(() => { - Shared.BATCH_SIZE = initialBatchSize; - Shared.__set__('BATCH_SIZE', initialBatchSize); + Backend.BATCH_SIZE = initialBatchSize; + Backend.__set__('BATCH_SIZE', initialBatchSize); }); it('move health_center_1 to district_2 in batches of 1', async () => { - Shared.__set__('BATCH_SIZE', 1); - Shared.BATCH_SIZE = 1; + Backend.__set__('BATCH_SIZE', 1); + Backend.BATCH_SIZE = 1; sinon.spy(pouchDb, 'query'); - await move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -596,11 +687,11 @@ describe('move-contacts', () => { }); it('should health_center_1 to district_1 in batches of 2', async () => { - Shared.__set__('BATCH_SIZE', 2); - Shared.BATCH_SIZE = 2; + Backend.__set__('BATCH_SIZE', 2); + Backend.BATCH_SIZE = 2; sinon.spy(pouchDb, 'query'); - await move(['health_center_1'], 'district_1', pouchDb); + await HierarchyOperations().move(['health_center_1'], 'district_1', pouchDb); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', diff --git a/test/lib/hierarchy-operations/mm-shared.spec.js b/test/lib/hierarchy-operations/jsdocs.spec.js similarity index 82% rename from test/lib/hierarchy-operations/mm-shared.spec.js rename to test/lib/hierarchy-operations/jsdocs.spec.js index 1d686ef17..d0aec6e11 100644 --- a/test/lib/hierarchy-operations/mm-shared.spec.js +++ b/test/lib/hierarchy-operations/jsdocs.spec.js @@ -4,18 +4,17 @@ const sinon = require('sinon'); const environment = require('../../../src/lib/environment'); const fs = require('../../../src/lib/sync-fs'); -const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); +const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder'); const userPrompt = rewire('../../../src/lib/user-prompt'); - -describe('mm-shared', () => { +describe('JsDocs', () => { let readline; let docOnj = { docDirectoryPath: '/test/path/for/testing ', force: false }; beforeEach(() => { readline = { keyInYN: sinon.stub() }; userPrompt.__set__('readline', readline); - Shared.__set__('userPrompt', userPrompt); + JsDocs.__set__('userPrompt', userPrompt); sinon.stub(fs, 'exists').returns(true); sinon.stub(fs, 'recurseFiles').returns(Array(20)); sinon.stub(fs, 'deleteFilesInFolder').returns(true); @@ -28,7 +27,7 @@ describe('mm-shared', () => { readline.keyInYN.returns(false); sinon.stub(environment, 'force').get(() => false); try { - Shared.prepareDocumentDirectory(docOnj); + JsDocs.prepareFolder(docOnj); assert.fail('Expected error to be thrown'); } catch(e) { assert.equal(fs.deleteFilesInFolder.callCount, 0); @@ -38,13 +37,13 @@ describe('mm-shared', () => { it('deletes files in directory when user presses y', () => { readline.keyInYN.returns(true); sinon.stub(environment, 'force').get(() => false); - Shared.prepareDocumentDirectory(docOnj); + JsDocs.prepareFolder(docOnj); assert.equal(fs.deleteFilesInFolder.callCount, 1); }); it('deletes files in directory when force is set', () => { sinon.stub(environment, 'force').get(() => true); - Shared.prepareDocumentDirectory(docOnj); + JsDocs.prepareFolder(docOnj); assert.equal(fs.deleteFilesInFolder.callCount, 1); }); }); diff --git a/test/lib/hierarchy-operations/merge-contacts.spec.js b/test/lib/hierarchy-operations/merge-contacts.spec.js deleted file mode 100644 index 120b6b602..000000000 --- a/test/lib/hierarchy-operations/merge-contacts.spec.js +++ /dev/null @@ -1,228 +0,0 @@ - -const chai = require('chai'); -const chaiAsPromised = require('chai-as-promised'); -const rewire = require('rewire'); - -const Shared = rewire('../../../src/lib/hierarchy-operations/mm-shared'); - -chai.use(chaiAsPromised); -const { expect } = chai; - -const PouchDB = require('pouchdb-core'); -PouchDB.plugin(require('pouchdb-adapter-memory')); -PouchDB.plugin(require('pouchdb-mapreduce')); - -const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); -HierarchyOperations.__set__('Shared', Shared); - -const move = HierarchyOperations({ merge: true }).move; - -const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); - -const contacts_by_depth = { - // eslint-disable-next-line quotes - map: "function(doc) {\n if (doc.type === 'tombstone' && doc.tombstone) {\n doc = doc.tombstone;\n }\n if (['contact', 'person', 'clinic', 'health_center', 'district_hospital'].indexOf(doc.type) !== -1) {\n var value = doc.patient_id || doc.place_id;\n var parent = doc;\n var depth = 0;\n while (parent) {\n if (parent._id) {\n emit([parent._id], value);\n emit([parent._id, depth], value);\n }\n depth++;\n parent = parent.parent;\n }\n }\n}", -}; - -const reports_by_freetext = { - // eslint-disable-next-line quotes - map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" -}; - -describe('merge-contacts', () => { - let pouchDb, scenarioCount = 0; - const writtenDocs = []; - const getWrittenDoc = docId => { - const matches = writtenDocs.filter(doc => doc && doc._id === docId); - if (matches.length === 0) { - return undefined; - } - - // Remove _rev because it makes expectations harder to write - const result = matches[matches.length - 1]; - 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: { - health_center_1: { - clinic_1: { - patient_1: {}, - }, - } - }, - district_2: { - health_center_2: { - clinic_2: { - patient_2: {}, - }, - } - }, - }); - - await pouchDb.put({ _id: 'settings', settings: {} }); - - await pouchDb.put({ - _id: '_design/medic-client', - views: { reports_by_freetext }, - }); - - await pouchDb.put({ - _id: '_design/medic', - views: { contacts_by_depth }, - }); - - Shared.writeDocumentToDisk = (docDirectoryPath, doc) => writtenDocs.push(doc); - Shared.prepareDocumentDirectory = () => {}; - writtenDocs.length = 0; - }); - - afterEach(async () => pouchDb.destroy()); - - it('merge district_2 into district_1', async () => { - // setup - await mockReport(pouchDb, { - id: 'changing_subject_and_contact', - creatorId: 'health_center_2_contact', - patientId: 'district_2' - }); - - await mockReport(pouchDb, { - id: 'changing_contact', - creatorId: 'health_center_2_contact', - patientId: 'patient_2' - }); - - await mockReport(pouchDb, { - id: 'changing_subject', - patientId: 'district_2' - }); - - // action - await move(['district_2'], '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, - }); - - expect(getWrittenDoc('health_center_2')).to.deep.eq({ - _id: 'health_center_2', - type: 'health_center', - contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), - parent: parentsToLineage('district_1'), - }); - - expect(getWrittenDoc('clinic_2')).to.deep.eq({ - _id: 'clinic_2', - type: 'clinic', - contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_1'), - parent: parentsToLineage('health_center_2', 'district_1'), - }); - - expect(getWrittenDoc('patient_2')).to.deep.eq({ - _id: 'patient_2', - type: 'person', - parent: parentsToLineage('clinic_2', 'health_center_2', 'district_1'), - }); - - expect(getWrittenDoc('changing_subject_and_contact')).to.deep.eq({ - _id: 'changing_subject_and_contact', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), - fields: { - patient_uuid: 'district_1' - } - }); - - expect(getWrittenDoc('changing_contact')).to.deep.eq({ - _id: 'changing_contact', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), - fields: { - patient_uuid: 'patient_2' - } - }); - - expect(getWrittenDoc('changing_subject')).to.deep.eq({ - _id: 'changing_subject', - form: 'foo', - type: 'data_record', - contact: { - _id: 'dne', - }, - fields: { - patient_uuid: 'district_1' - } - }); - }); - - 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 move(['patient_2'], '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 = move(['dne'], 'district_1', pouchDb); - await expect(actual).to.eventually.rejectedWith('could not be found'); - }); - - it('throw if kept does not exist', async () => { - const actual = move(['district_1'], 'dne', pouchDb); - await expect(actual).to.eventually.rejectedWith('could not be found'); - }); - - it('throw if removed is kept', async () => { - const actual = move(['district_1', 'district_2'], 'district_2', pouchDb); - await expect(actual).to.eventually.rejectedWith('that is itself'); - }); -}); From 2442fcccf235a27bfb14ebded3fb6bae38695e98 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 19:15:22 -0700 Subject: [PATCH 14/66] Pass eslint --- src/lib/hierarchy-operations/backend.js | 2 +- src/lib/hierarchy-operations/lineage-manipulation.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/hierarchy-operations/backend.js b/src/lib/hierarchy-operations/backend.js index dd794c125..d1755fc67 100644 --- a/src/lib/hierarchy-operations/backend.js +++ b/src/lib/hierarchy-operations/backend.js @@ -84,7 +84,7 @@ const fetch = { const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); if (ancestorIdsNotFound.length > 0) { - throw Error(`Contact '${prettyPrintDocument(contactDoc)} has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); + throw Error(`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); } return ancestors.rows.map(ancestor => ancestor.doc); diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index ebd7df97f..8df1aa772 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -27,7 +27,7 @@ const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdIn element: doc[lineageAttributeName], attributeName: 'parent', }; - } + }; const state = initialState(); while (state.element) { From a0a0c845e6c50a249282912dc3adee34202f87e6 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 19:19:59 -0700 Subject: [PATCH 15/66] Backend interface change --- src/lib/hierarchy-operations/backend.js | 168 ++++++++++++------------ src/lib/hierarchy-operations/index.js | 10 +- 2 files changed, 90 insertions(+), 88 deletions(-) diff --git a/src/lib/hierarchy-operations/backend.js b/src/lib/hierarchy-operations/backend.js index d1755fc67..5a27882c4 100644 --- a/src/lib/hierarchy-operations/backend.js +++ b/src/lib/hierarchy-operations/backend.js @@ -4,95 +4,97 @@ const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; const BATCH_SIZE = 10000; -const fetch = { - /* - Fetches all of the documents associated with the "contactIds" and confirms they exist. - */ - contactList: async (db, ids) => { - const contactDocs = await db.allDocs({ - keys: ids, - include_docs: true, - }); - - const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); - if (missingContactErrors.length > 0) { - throw Error(missingContactErrors); +/* +Fetches all of the documents associated with the "contactIds" and confirms they exist. +*/ +async function contactList(db, ids) { + const contactDocs = await db.allDocs({ + keys: ids, + include_docs: true, + }); + + const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); + if (missingContactErrors.length > 0) { + throw Error(missingContactErrors); + } + + return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); +} + +async function contact(db, id) { + try { + if (id === HIERARCHY_ROOT) { + return undefined; } - return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); - }, - - contact: async (db, id) => { - try { - if (id === HIERARCHY_ROOT) { - return undefined; - } - - return await db.get(id); - } catch (err) { - if (err.name !== 'not_found') { - throw err; - } - - throw Error(`Contact with id '${id}' could not be found`); - } - }, - - /* - Given a contact's id, obtain the documents of all descendant contacts - */ - descendantsOf: async (db, contactId) => { - const descendantDocs = await db.query('medic/contacts_by_depth', { - key: [contactId], - include_docs: true, - }); - - return descendantDocs.rows - .map(row => row.doc) - /* We should not move or update tombstone documents */ - .filter(doc => doc && doc.type !== 'tombstone'); - }, - - reportsCreatedByOrAt: async (db, createdByIds, createdAtId, skip) => { - const createdByKeys = createdByIds.map(descendantId => [`contact:${descendantId}`]); - const createdAtKeys = createdAtId ? [ - [`patient_id:${createdAtId}`], - [`patient_uuid:${createdAtId}`], - [`place_id:${createdAtId}`], - [`place_uuid:${createdAtId}`] - ] : []; - - const reports = await db.query('medic-client/reports_by_freetext', { - keys: [ - ...createdByKeys, - ...createdAtKeys, - ], - include_docs: true, - limit: BATCH_SIZE, - skip, - }); - - return _.uniqBy(reports.rows.map(row => row.doc), '_id'); - }, - - ancestorsOf: async (db, contactDoc) => { - const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); - const ancestors = await db.allDocs({ - keys: ancestorIds, - include_docs: true, - }); - - const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); - if (ancestorIdsNotFound.length > 0) { - throw Error(`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); + return await db.get(id); + } catch (err) { + if (err.name !== 'not_found') { + throw err; } - return ancestors.rows.map(ancestor => ancestor.doc); - }, -}; + throw Error(`Contact with id '${id}' could not be found`); + } +} + +/* +Given a contact's id, obtain the documents of all descendant contacts +*/ +async function descendantsOf(db, contactId) { + const descendantDocs = await db.query('medic/contacts_by_depth', { + key: [contactId], + include_docs: true, + }); + + return descendantDocs.rows + .map(row => row.doc) + /* We should not move or update tombstone documents */ + .filter(doc => doc && doc.type !== 'tombstone'); +} + +async function reportsCreatedByOrAt(db, createdByIds, createdAtId, skip) { + const createdByKeys = createdByIds.map(descendantId => [`contact:${descendantId}`]); + const createdAtKeys = createdAtId ? [ + [`patient_id:${createdAtId}`], + [`patient_uuid:${createdAtId}`], + [`place_id:${createdAtId}`], + [`place_uuid:${createdAtId}`] + ] : []; + + const reports = await db.query('medic-client/reports_by_freetext', { + keys: [ + ...createdByKeys, + ...createdAtKeys, + ], + include_docs: true, + limit: BATCH_SIZE, + skip, + }); + + return _.uniqBy(reports.rows.map(row => row.doc), '_id'); +} + +async function ancestorsOf(db, contactDoc) { + const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); + const ancestors = await db.allDocs({ + keys: ancestorIds, + include_docs: true, + }); + + const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); + if (ancestorIdsNotFound.length > 0) { + throw Error(`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); + } + + return ancestors.rows.map(ancestor => ancestor.doc); +} module.exports = { HIERARCHY_ROOT, BATCH_SIZE, - fetch, + ancestorsOf, + descendantsOf, + contact, + contactList, + reportsCreatedByOrAt, }; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 5b72875ba..62014d154 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -10,15 +10,15 @@ const HierarchyOperations = (options) => { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); - const destinationDoc = await Backend.fetch.contact(db, destinationId); - const sourceDocs = await Backend.fetch.contactList(db, sourceIds); + const destinationDoc = await Backend.contact(db, destinationId); + const sourceDocs = await Backend.contactList(db, sourceIds); await constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); for (let sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; - const descendantsAndSelf = await Backend.fetch.descendantsOf(db, sourceId); + const descendantsAndSelf = await Backend.descendantsOf(db, sourceId); if (options.merge) { const self = descendantsAndSelf.find(d => d._id === sourceId); @@ -39,7 +39,7 @@ const HierarchyOperations = (options) => { trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, sourceId); - const ancestors = await Backend.fetch.ancestorsOf(db, sourceDoc); + const ancestors = await Backend.ancestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); @@ -66,7 +66,7 @@ const HierarchyOperations = (options) => { do { info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); const createdAtId = options.merge && sourceId; - reportDocsBatch = await Backend.fetch.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + reportDocsBatch = await Backend.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); From f73f9c6b48c59063f9d66de81858bea03fe96004 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 19:34:32 -0700 Subject: [PATCH 16/66] Fix failing test in mock-hierarchies --- src/lib/hierarchy-operations/backend.js | 2 +- src/lib/hierarchy-operations/index.js | 134 +++++++++++++----------- test/mock-hierarchies.spec.js | 1 + 3 files changed, 74 insertions(+), 63 deletions(-) diff --git a/src/lib/hierarchy-operations/backend.js b/src/lib/hierarchy-operations/backend.js index 5a27882c4..30990d8b3 100644 --- a/src/lib/hierarchy-operations/backend.js +++ b/src/lib/hierarchy-operations/backend.js @@ -53,7 +53,7 @@ async function descendantsOf(db, contactId) { } async function reportsCreatedByOrAt(db, createdByIds, createdAtId, skip) { - const createdByKeys = createdByIds.map(descendantId => [`contact:${descendantId}`]); + const createdByKeys = createdByIds.map(id => [`contact:${id}`]); const createdAtKeys = createdAtId ? [ [`patient_id:${createdAtId}`], [`patient_uuid:${createdAtId}`], diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 62014d154..e4d4fc4d3 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -6,7 +6,7 @@ const JsDocs = require('./jsdocFolder'); const Backend = require('./backend'); const HierarchyOperations = (options) => { - const move = async (sourceIds, destinationId, db) => { + async function move(sourceIds, destinationId, db) { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); @@ -55,10 +55,9 @@ const HierarchyOperations = (options) => { } info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); - }; + } - - const moveReports = async (db, descendantsAndSelf, replacementLineage, sourceId, destinationId) => { + async function moveReports(db, descendantsAndSelf, replacementLineage, sourceId, destinationId) { const descendantIds = descendantsAndSelf.map(contact => contact._id); let skip = 0; @@ -71,28 +70,7 @@ const HierarchyOperations = (options) => { const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); if (options.merge) { - reportDocsBatch.forEach(report => { - let updated = false; - const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; - for (const subjectId of subjectIds) { - if (report[subjectId] === sourceId) { - report[subjectId] = destinationId; - updated = true; - } - - if (report.fields[subjectId] === sourceId) { - report.fields[subjectId] = destinationId; - updated = true; - } - - if (updated) { - const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); - if (!isAlreadyUpdated) { - updatedReports.push(report); - } - } - } - }); + reassignReports(reportDocsBatch, sourceId, destinationId, updatedReports); } minifyLineageAndWriteToDisk(updatedReports); @@ -101,51 +79,82 @@ const HierarchyOperations = (options) => { } while (reportDocsBatch.length >= Backend.BATCH_SIZE); return skip; - }; + } + + function reassignReports(reports, sourceId, destinationId, updatedReports) { + reports.forEach(report => { + let updated = false; + const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + for (const subjectId of subjectIds) { + if (report[subjectId] === sourceId) { + report[subjectId] = destinationId; + updated = true; + } + + if (report.fields[subjectId] === sourceId) { + report.fields[subjectId] = destinationId; + updated = true; + } + + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); + } + } + } + }); + } - const minifyLineageAndWriteToDisk = (docs) => { + function minifyLineageAndWriteToDisk(docs) { docs.forEach(doc => { lineageManipulation.minifyLineagesInDoc(doc); JsDocs.writeDoc(options, doc); }); - }; - - const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage, options)) { - agg.push(doc); - } - return agg; - }, []); - - const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); - if (primaryContact) { - ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; - } - - return result; - }, []); + } - const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, destinationId) => descendantsAndSelf.reduce((agg, doc) => { - const startingFromIdInLineage = options.merge ? destinationId : - doc._id === destinationId ? undefined : destinationId; + function replaceLineageInReports(reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) { + return reportsCreatedByDescendants.reduce((agg, doc) => { + if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage, options)) { + agg.push(doc); + } + return agg; + }, []); + } + + function replaceLineageInAncestors(descendantsAndSelf, ancestors) { + return ancestors.reduce((agg, ancestor) => { + let result = agg; + const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + if (primaryContact) { + ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); + result = [ancestor, ...result]; + } - // skip top-level because it will be deleted - if (options.merge) { - if (doc._id === destinationId) { - return agg; + return result; + }, []); + } + + function replaceLineageInContacts(descendantsAndSelf, replacementLineage, destinationId) { + return descendantsAndSelf.reduce((agg, doc) => { + const startingFromIdInLineage = options.merge ? destinationId : + doc._id === destinationId ? undefined : destinationId; + + // skip top-level because it will be deleted + if (options.merge) { + if (doc._id === destinationId) { + return agg; + } } - } - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); - if (parentWasUpdated || contactWasUpdated) { - agg.push(doc); - } - return agg; - }, []); + const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); + const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); + if (parentWasUpdated || contactWasUpdated) { + agg.push(doc); + } + return agg; + }, []); + } return { move }; }; @@ -155,3 +164,4 @@ module.exports = options => ({ move: HierarchyOperations({ ...options, merge: false }).move, merge: HierarchyOperations({ ...options, merge: true }).move, }); + diff --git a/test/mock-hierarchies.spec.js b/test/mock-hierarchies.spec.js index c8a21933a..3177a7172 100644 --- a/test/mock-hierarchies.spec.js +++ b/test/mock-hierarchies.spec.js @@ -84,6 +84,7 @@ describe('mocks', () => { _id: 'report_1', type: 'data_record', form: 'foo', + fields: {}, contact: { _id: 'health_center_1_contact', parent: { From 8e35f2d449cc25e12db066f8e356cdeee59290ef Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 20:01:55 -0700 Subject: [PATCH 17/66] SonarCube --- src/fn/merge-contacts.js | 2 +- src/fn/move-contacts.js | 2 +- src/lib/hierarchy-operations/index.js | 29 +++++++------- src/lib/hierarchy-operations/jsdocFolder.js | 25 +++++++----- .../lineage-constraints.js | 4 +- .../lineage-manipulation.js | 11 +++--- .../hierarchy-operations.spec.js | 39 ++++++++++--------- test/lib/hierarchy-operations/jsdocs.spec.js | 11 ++---- 8 files changed, 62 insertions(+), 61 deletions(-) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 6ad0edb98..591f9465c 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -16,7 +16,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options).merge(args.sourceIds, args.destinationId, db); + return HierarchyOperations(options, db).merge(args.sourceIds, args.destinationId); } }; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 75de128dc..d3af4334b 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -16,7 +16,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options).move(args.sourceIds, args.destinationId, db); + return HierarchyOperations(options, db).move(args.sourceIds, args.destinationId); } }; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index e4d4fc4d3..655f424c4 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -5,14 +5,14 @@ const { trace, info } = require('../log'); const JsDocs = require('./jsdocFolder'); const Backend = require('./backend'); -const HierarchyOperations = (options) => { - async function move(sourceIds, destinationId, db) { +const HierarchyOperations = (db, options) => { + async function move(sourceIds, destinationId) { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); const destinationDoc = await Backend.contact(db, destinationId); const sourceDocs = await Backend.contactList(db, sourceIds); - await constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); + constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); @@ -45,7 +45,7 @@ const HierarchyOperations = (options) => { minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors]); - const movedReportsCount = await moveReports(db, descendantsAndSelf, replacementLineage, sourceId, destinationId); + const movedReportsCount = await moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -57,7 +57,7 @@ const HierarchyOperations = (options) => { info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); } - async function moveReports(db, descendantsAndSelf, replacementLineage, sourceId, destinationId) { + async function moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId) { const descendantIds = descendantsAndSelf.map(contact => contact._id); let skip = 0; @@ -137,19 +137,18 @@ const HierarchyOperations = (options) => { function replaceLineageInContacts(descendantsAndSelf, replacementLineage, destinationId) { return descendantsAndSelf.reduce((agg, doc) => { - const startingFromIdInLineage = options.merge ? destinationId : - doc._id === destinationId ? undefined : destinationId; + const docIsDestination = doc._id === destinationId; + const startingFromIdInLineage = options.merge || !docIsDestination ? destinationId : undefined; // skip top-level because it will be deleted - if (options.merge) { - if (doc._id === destinationId) { - return agg; - } + if (options.merge && docIsDestination) { + return agg; } const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); - if (parentWasUpdated || contactWasUpdated) { + const isUpdated = parentWasUpdated || contactWasUpdated; + if (isUpdated) { agg.push(doc); } return agg; @@ -159,9 +158,9 @@ const HierarchyOperations = (options) => { return { move }; }; -module.exports = options => ({ +module.exports = (db, options) => ({ HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, - move: HierarchyOperations({ ...options, merge: false }).move, - merge: HierarchyOperations({ ...options, merge: true }).move, + move: HierarchyOperations(db, { ...options, merge: false }).move, + merge: HierarchyOperations(db, { ...options, merge: true }).move, }); diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js index fd46ca5cf..a45836c4d 100644 --- a/src/lib/hierarchy-operations/jsdocFolder.js +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -3,20 +3,15 @@ const userPrompt = require('../user-prompt'); const fs = require('../sync-fs'); const { warn, trace } = require('../log'); -const prepareFolder = ({ docDirectoryPath, force }) => { +function prepareFolder({ docDirectoryPath, force }) { if (!fs.exists(docDirectoryPath)) { fs.mkdir(docDirectoryPath); } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { - warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); - if(userPrompt.keyInYN()) { - fs.deleteFilesInFolder(docDirectoryPath); - } else { - throw new Error('User aborted execution.'); - } + deleteAfterConfirmation(docDirectoryPath); } -}; +} -const writeDoc = ({ docDirectoryPath }, doc) => { +function writeDoc({ docDirectoryPath }, doc) { const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); if (fs.exists(destinationPath)) { warn(`File at ${destinationPath} already exists and is being overwritten.`); @@ -24,9 +19,19 @@ const writeDoc = ({ docDirectoryPath }, doc) => { trace(`Writing updated document to ${destinationPath}`); fs.writeJson(destinationPath, doc); -}; +} + +function deleteAfterConfirmation(docDirectoryPath) { + warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); + if (userPrompt.keyInYN()) { + fs.deleteFilesInFolder(docDirectoryPath); + } else { + throw new Error('User aborted execution.'); + } +} module.exports = { prepareFolder, writeDoc, }; + diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 91d2845d1..84f0d8591 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -127,8 +127,8 @@ const getPrimaryContactViolations = async (db, contactDoc, parentDoc, descendant }); const primaryContactIds = docsRemovedFromContactLineage.rows - .map(row => row.doc && row.doc.contact && row.doc.contact._id) - .filter(id => id); + .map(row => row?.doc?.contact?._id) + .filter(Boolean); return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); }; diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 8df1aa772..0add21ac3 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -9,13 +9,13 @@ * @param {Object} options * @param {boolean} merge When true, startingFromIdInLineage is replaced and when false, startingFromIdInLineage's parent is replaced */ -const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage, options={}) => { +function replaceLineage(doc, lineageAttributeName, replaceWith, startingFromIdInLineage, options={}) { // Replace the full lineage if (!startingFromIdInLineage) { return replaceWithinLineage(doc, lineageAttributeName, replaceWith); } - const initialState = () => { + const getInitialState = () => { if (options.merge) { return { element: doc, @@ -29,7 +29,7 @@ const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdIn }; }; - const state = initialState(); + const state = getInitialState(); while (state.element) { const compare = options.merge ? state.element[state.attributeName] : state.element; if (compare?._id === startingFromIdInLineage) { @@ -41,7 +41,7 @@ const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdIn } return false; -}; +} const replaceWithinLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { if (!replaceWith) { @@ -63,7 +63,7 @@ Function borrowed from shared-lib/lineage */ const minifyLineagesInDoc = doc => { const minifyLineage = lineage => { - if (!lineage || !lineage._id) { + if (!lineage?._id) { return undefined; } @@ -85,7 +85,6 @@ const minifyLineagesInDoc = doc => { if ('contact' in doc) { doc.contact = minifyLineage(doc.contact); - if (doc.contact && !doc.contact.parent) delete doc.contact.parent; // for unit test clarity } if (doc.type === 'data_record') { diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 525cb00cc..3d6eee0ab 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -99,7 +99,7 @@ describe('move-contacts', () => { afterEach(async () => pouchDb.destroy()); it('move health_center_1 to district_2', async () => { - await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -141,7 +141,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: [] }]); - await HierarchyOperations().move(['health_center_1'], 'root', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'root'); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -194,7 +194,7 @@ describe('move-contacts', () => { it('move district_1 from root', async () => { await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - await HierarchyOperations().move(['district_1'], 'district_2', pouchDb); + await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); expect(getWrittenDoc('district_1')).to.deep.eq({ _id: 'district_1', @@ -250,7 +250,7 @@ describe('move-contacts', () => { { id: 'district_hospital', parents: ['county'] }, ]); - await HierarchyOperations().move(['district_1'], 'county_1', pouchDb); + await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -305,7 +305,7 @@ describe('move-contacts', () => { creatorId: 'focal', }); - await HierarchyOperations().move(['focal'], 'subcounty', pouchDb); + await HierarchyOperations(pouchDb).move(['focal'], 'subcounty'); expect(getWrittenDoc('focal')).to.deep.eq({ _id: 'focal', @@ -350,7 +350,7 @@ describe('move-contacts', () => { parent: parentsToLineage(), }); - await HierarchyOperations().move(['t_patient_1'], 't_clinic_2', pouchDb); + await HierarchyOperations(pouchDb).move(['t_patient_1'], 't_clinic_2'); expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ _id: 't_health_center_1', @@ -371,7 +371,7 @@ describe('move-contacts', () => { // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' it('district_hospital with empty string parent is not preserved', async () => { await upsert('district_2', { parent: '', type: 'district_hospital' }); - await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); expect(getWrittenDoc('health_center_1')).to.deep.eq({ _id: 'health_center_1', @@ -402,7 +402,7 @@ describe('move-contacts', () => { }); // action - await HierarchyOperations().merge(['district_2'], 'district_1', pouchDb); + await HierarchyOperations(pouchDb).merge(['district_2'], 'district_1'); // assert expectWrittenDocs([ @@ -464,6 +464,7 @@ describe('move-contacts', () => { type: 'data_record', contact: { _id: 'dne', + parent: undefined, }, fields: { patient_uuid: 'district_1' @@ -486,7 +487,7 @@ describe('move-contacts', () => { }); // action - await HierarchyOperations().merge(['patient_2'], 'patient_1', pouchDb); + await HierarchyOperations(pouchDb).merge(['patient_2'], 'patient_1'); await expectWrittenDocs(['patient_2', 'pat2']); @@ -526,7 +527,7 @@ describe('move-contacts', () => { await upsert('clinic_1', clinic); await upsert('patient_1', patient); - await HierarchyOperations().move(['clinic_1'], 'district_2', pouchDb); + await HierarchyOperations(pouchDb).move(['clinic_1'], 'district_2'); expect(getWrittenDoc('clinic_1')).to.deep.eq({ _id: 'clinic_1', @@ -547,7 +548,7 @@ describe('move-contacts', () => { await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); try { - await HierarchyOperations().move(['health_center_1'], 'clinic_1', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); assert.fail('should throw'); } catch (err) { expect(err.message).to.include('circular'); @@ -555,34 +556,34 @@ describe('move-contacts', () => { }); it('throw if parent does not exist', async () => { - const actual = HierarchyOperations().move(['clinic_1'], 'dne_parent_id', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'dne_parent_id'); await expect(actual).to.eventually.rejectedWith('could not be found'); }); it('throw when altering same lineage', async () => { - const actual = HierarchyOperations().move(['patient_1', 'health_center_1'], 'district_2', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['patient_1', 'health_center_1'], 'district_2'); await expect(actual).to.eventually.rejectedWith('same lineage'); }); it('throw if contact_id is not a contact', async () => { - const actual = HierarchyOperations().move(['report_1'], 'clinic_1', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['report_1'], 'clinic_1'); await expect(actual).to.eventually.rejectedWith('unknown type'); }); it('throw if moving primary contact of parent', async () => { - const actual = HierarchyOperations().move(['clinic_1_contact'], 'district_1', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['clinic_1_contact'], 'district_1'); await expect(actual).to.eventually.rejectedWith('primary contact'); }); it('throw if setting parent to self', async () => { await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); - const actual = HierarchyOperations().move(['clinic_1'], 'clinic_1', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); await expect(actual).to.eventually.rejectedWith('circular'); }); it('throw when moving place to unconfigured parent', async () => { await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); - const actual = HierarchyOperations().move(['district_1'], 'district_2', pouchDb); + const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); await expect(actual).to.eventually.rejectedWith('parent of type'); }); @@ -615,7 +616,7 @@ describe('move-contacts', () => { Backend.BATCH_SIZE = 1; sinon.spy(pouchDb, 'query'); - await HierarchyOperations().move(['health_center_1'], 'district_2', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', @@ -691,7 +692,7 @@ describe('move-contacts', () => { Backend.BATCH_SIZE = 2; sinon.spy(pouchDb, 'query'); - await HierarchyOperations().move(['health_center_1'], 'district_1', pouchDb); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ _id: 'health_center_1_contact', diff --git a/test/lib/hierarchy-operations/jsdocs.spec.js b/test/lib/hierarchy-operations/jsdocs.spec.js index d0aec6e11..23353673a 100644 --- a/test/lib/hierarchy-operations/jsdocs.spec.js +++ b/test/lib/hierarchy-operations/jsdocs.spec.js @@ -1,4 +1,4 @@ -const { assert } = require('chai'); +const { assert, expect } = require('chai'); const rewire = require('rewire'); const sinon = require('sinon'); @@ -26,12 +26,9 @@ describe('JsDocs', () => { it('does not delete files in directory when user presses n', () => { readline.keyInYN.returns(false); sinon.stub(environment, 'force').get(() => false); - try { - JsDocs.prepareFolder(docOnj); - assert.fail('Expected error to be thrown'); - } catch(e) { - assert.equal(fs.deleteFilesInFolder.callCount, 0); - } + const actual = () => JsDocs.prepareFolder(docOnj); + expect(actual).to.throw('aborted execution'); + assert.equal(fs.deleteFilesInFolder.callCount, 0); }); it('deletes files in directory when user presses y', () => { From 17c4c047a00e92bcdc63c1492fce9db110f7fc30 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 20:39:09 -0700 Subject: [PATCH 18/66] SonarQube - Is his really better code? --- src/lib/hierarchy-operations/index.js | 52 +++++++++++-------- .../lineage-constraints.js | 46 ++++++++-------- .../lineage-manipulation.js | 15 ++++-- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 655f424c4..fd6b5f6dc 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -82,28 +82,32 @@ const HierarchyOperations = (db, options) => { } function reassignReports(reports, sourceId, destinationId, updatedReports) { - reports.forEach(report => { + function reassignReportWithSubject(report, subjectId) { let updated = false; + if (report[subjectId] === sourceId) { + report[subjectId] = destinationId; + updated = true; + } + + if (report.fields[subjectId] === sourceId) { + report.fields[subjectId] = destinationId; + updated = true; + } + + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); + } + } + } + + for (const report of reports) { const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; for (const subjectId of subjectIds) { - if (report[subjectId] === sourceId) { - report[subjectId] = destinationId; - updated = true; - } - - if (report.fields[subjectId] === sourceId) { - report.fields[subjectId] = destinationId; - updated = true; - } - - if (updated) { - const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); - if (!isAlreadyUpdated) { - updatedReports.push(report); - } - } + reassignReportWithSubject(report, subjectId); } - }); + } } function minifyLineageAndWriteToDisk(docs) { @@ -136,23 +140,25 @@ const HierarchyOperations = (db, options) => { } function replaceLineageInContacts(descendantsAndSelf, replacementLineage, destinationId) { - return descendantsAndSelf.reduce((agg, doc) => { + const result = []; + for (const doc of descendantsAndSelf) { const docIsDestination = doc._id === destinationId; const startingFromIdInLineage = options.merge || !docIsDestination ? destinationId : undefined; // skip top-level because it will be deleted if (options.merge && docIsDestination) { - return agg; + continue; } const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); const isUpdated = parentWasUpdated || contactWasUpdated; if (isUpdated) { - agg.push(doc); + result.push(doc); } - return agg; - }, []); + } + + return result; } return { move }; diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 84f0d8591..5bfbae19f 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -47,33 +47,37 @@ Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { - const commonViolations = getCommonViolations(sourceDoc, destinationDoc); - if (commonViolations) { - return commonViolations; - } + function getContactTypeError() { + const sourceContactType = getContactType(sourceDoc); + const destinationType = getContactType(destinationDoc); + const rulesForContact = mapTypeToAllowedParents[sourceContactType]; + if (!rulesForContact) { + return `cannot move contact with unknown type '${sourceContactType}'`; + } - if (!mapTypeToAllowedParents) { - return 'hierarchy constraints are undefined'; - } - - const sourceContactType = getContactType(sourceDoc); - const destinationType = getContactType(destinationDoc); - const rulesForContact = mapTypeToAllowedParents[sourceContactType]; - if (!rulesForContact) { - return `cannot move contact with unknown type '${sourceContactType}'`; + const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; + if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { + return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; + } } - const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; - if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { - return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; + function findCircularHierarchyErrors() { + if (destinationDoc && sourceDoc._id) { + const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; + if (parentAncestry.includes(sourceDoc._id)) { + return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; + } + } } - if (destinationDoc && sourceDoc._id) { - const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; - if (parentAncestry.includes(sourceDoc._id)) { - return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; - } + if (!mapTypeToAllowedParents) { + return 'hierarchy constraints are undefined'; } + + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + const contactTypeError = getContactTypeError(); + const circularHierarchyError = findCircularHierarchyErrors(); + return commonViolations || contactTypeError || circularHierarchyError; }; const getCommonViolations = (sourceDoc, destinationDoc) => { diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 0add21ac3..e4967a7e6 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -15,7 +15,7 @@ function replaceLineage(doc, lineageAttributeName, replaceWith, startingFromIdIn return replaceWithinLineage(doc, lineageAttributeName, replaceWith); } - const getInitialState = () => { + function getInitialState() { if (options.merge) { return { element: doc, @@ -27,10 +27,9 @@ function replaceLineage(doc, lineageAttributeName, replaceWith, startingFromIdIn element: doc[lineageAttributeName], attributeName: 'parent', }; - }; + } - const state = getInitialState(); - while (state.element) { + function traverseOne() { const compare = options.merge ? state.element[state.attributeName] : state.element; if (compare?._id === startingFromIdInLineage) { return replaceWithinLineage(state.element, state.attributeName, replaceWith); @@ -40,6 +39,14 @@ function replaceLineage(doc, lineageAttributeName, replaceWith, startingFromIdIn state.attributeName = 'parent'; } + const state = getInitialState(); + while (state.element) { + const result = traverseOne(); + if (result) { + return result; + } + } + return false; } From 7af035cd5df3fa10a993501ccc40b35200582a26 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 20:45:24 -0700 Subject: [PATCH 19/66] SonarQube - Fix? --- src/lib/hierarchy-operations/index.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index fd6b5f6dc..f19a8e21f 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -140,22 +140,27 @@ const HierarchyOperations = (db, options) => { } function replaceLineageInContacts(descendantsAndSelf, replacementLineage, destinationId) { + function replaceForSingleContact(doc) { + const docIsDestination = doc._id === destinationId; + const startingFromIdInLineage = options.merge || !docIsDestination ? destinationId : undefined; + const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); + const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); + const isUpdated = parentWasUpdated || contactWasUpdated; + if (isUpdated) { + result.push(doc); + } + } + const result = []; for (const doc of descendantsAndSelf) { const docIsDestination = doc._id === destinationId; - const startingFromIdInLineage = options.merge || !docIsDestination ? destinationId : undefined; // skip top-level because it will be deleted if (options.merge && docIsDestination) { continue; } - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); - const isUpdated = parentWasUpdated || contactWasUpdated; - if (isUpdated) { - result.push(doc); - } + replaceForSingleContact(doc); } return result; From 687a6a231f088d53980754e0c03e48478d02fb2d Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 20:59:24 -0700 Subject: [PATCH 20/66] SonarQube --- src/lib/hierarchy-operations/index.js | 30 +++++++-- .../lineage-manipulation.js | 28 ++++---- .../lineage-manipulation.spec.js | 66 +++++++++++++++---- 3 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index f19a8e21f..19b6b861b 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -117,9 +117,16 @@ const HierarchyOperations = (db, options) => { }); } - function replaceLineageInReports(reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) { + function replaceLineageInReports(reportsCreatedByDescendants, replaceWith, startingFromId) { return reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage, options)) { + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith, + startingFromId, + merge: options.merge, + }; + + if (lineageManipulation.replaceLineage(doc, replaceLineageOptions)) { agg.push(doc); } return agg; @@ -139,18 +146,27 @@ const HierarchyOperations = (db, options) => { }, []); } - function replaceLineageInContacts(descendantsAndSelf, replacementLineage, destinationId) { + function replaceLineageInContacts(descendantsAndSelf, replaceWith, destinationId) { function replaceForSingleContact(doc) { const docIsDestination = doc._id === destinationId; - const startingFromIdInLineage = options.merge || !docIsDestination ? destinationId : undefined; - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage, options); - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, destinationId, options); + const startingFromId = options.merge || !docIsDestination ? destinationId : undefined; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith, + startingFromId, + merge: options.merge, + }; + const parentWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + + replaceLineageOptions.lineageAttribute = 'contact'; + replaceLineageOptions.startingFromId = destinationId; + const contactWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); const isUpdated = parentWasUpdated || contactWasUpdated; if (isUpdated) { result.push(doc); } } - + const result = []; for (const doc of descendantsAndSelf) { const docIsDestination = doc._id === destinationId; diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index e4967a7e6..f966e330c 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -3,35 +3,37 @@ * Given a doc, replace the lineage information therein with "replaceWith" * * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing - * @param {string} lineageAttributeName Name of the attribute which is a lineage in doc (contact or parent) - * @param {Object} replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } - * @param {string} [startingFromIdInLineage] Only the part of the lineage "after" this id will be replaced - * @param {Object} options - * @param {boolean} merge When true, startingFromIdInLineage is replaced and when false, startingFromIdInLineage's parent is replaced + * @param {Object} params SonarQube + * @param {string} params.lineageAttribute Name of the attribute which is a lineage in doc (contact or parent) + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced */ -function replaceLineage(doc, lineageAttributeName, replaceWith, startingFromIdInLineage, options={}) { +function replaceLineage(doc, params) { + const { lineageAttribute, replaceWith, startingFromId, merge } = params; + // Replace the full lineage - if (!startingFromIdInLineage) { - return replaceWithinLineage(doc, lineageAttributeName, replaceWith); + if (!startingFromId) { + return replaceWithinLineage(doc, lineageAttribute, replaceWith); } function getInitialState() { - if (options.merge) { + if (merge) { return { element: doc, - attributeName: lineageAttributeName, + attributeName: lineageAttribute, }; } return { - element: doc[lineageAttributeName], + element: doc[lineageAttribute], attributeName: 'parent', }; } function traverseOne() { - const compare = options.merge ? state.element[state.attributeName] : state.element; - if (compare?._id === startingFromIdInLineage) { + const compare = merge ? state.element[state.attributeName] : state.element; + if (compare?._id === startingFromId) { return replaceWithinLineage(state.element, state.attributeName, replaceWith); } diff --git a/test/lib/hierarchy-operations/lineage-manipulation.spec.js b/test/lib/hierarchy-operations/lineage-manipulation.spec.js index be324009e..80077aa9f 100644 --- a/test/lib/hierarchy-operations/lineage-manipulation.spec.js +++ b/test/lib/hierarchy-operations/lineage-manipulation.spec.js @@ -4,16 +4,19 @@ const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; const { parentsToLineage } = require('../../mock-hierarchies'); -const mergeOption = { merge: true }; describe('lineage manipulation', () => { - describe('kenn replaceLineage', () => { + describe('replaceLineage', () => { const mockReport = data => Object.assign({ _id: 'r', type: 'data_record', contact: parentsToLineage('parent', 'grandparent') }, data); const mockContact = data => Object.assign({ _id: 'c', type: 'person', parent: parentsToLineage('parent', 'grandparent') }, data); it('replace with empty lineage', () => { const mock = mockReport(); - expect(replaceLineage(mock, 'contact', undefined)).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith: undefined, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'r', type: 'data_record', @@ -23,7 +26,11 @@ describe('lineage manipulation', () => { it('replace full lineage', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent'), + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -35,7 +42,11 @@ describe('lineage manipulation', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent'), + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -46,12 +57,23 @@ describe('lineage manipulation', () => { it('replace empty with empty', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', undefined)).to.be.false; + + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: undefined, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; }); it('replace lineage starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; + + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_grandparent'), + startingFromId: 'parent', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -61,7 +83,13 @@ describe('lineage manipulation', () => { it('merge new parent', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent', 'new_grandparent'), 'parent', mergeOption)).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent', 'new_grandparent'), + startingFromId: 'parent', + merge: true, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -71,7 +99,13 @@ describe('lineage manipulation', () => { it('merge grandparent of contact', () => { const mock = mockReport(); - expect(replaceLineage(mock, 'contact', parentsToLineage('new_grandparent'), 'grandparent', mergeOption)).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith: parentsToLineage('new_grandparent'), + startingFromId: 'grandparent', + merge: true, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'r', type: 'data_record', @@ -81,7 +115,12 @@ describe('lineage manipulation', () => { it('replace empty starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', undefined, 'parent')).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: undefined, + startingFromId: 'parent', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -91,7 +130,12 @@ describe('lineage manipulation', () => { it('replace starting at non-existant contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('irrelevant'), + startingFromId: 'dne', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; }); }); From 49c6d5149d99219ef7e04dc4092a94cca0796dd2 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 22 Nov 2024 21:12:03 -0700 Subject: [PATCH 21/66] Oops --- src/fn/merge-contacts.js | 2 +- src/fn/move-contacts.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 591f9465c..ef3f20211 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -16,7 +16,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options, db).merge(args.sourceIds, args.destinationId); + return HierarchyOperations(db, options).merge(args.sourceIds, args.destinationId); } }; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index d3af4334b..97dc8e142 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -16,7 +16,7 @@ module.exports = { docDirectoryPath: args.docDirectoryPath, force: args.force, }; - return HierarchyOperations(options, db).move(args.sourceIds, args.destinationId); + return HierarchyOperations(db, options).move(args.sourceIds, args.destinationId); } }; From 9536ff6e8b34135a0fe83539504815e2e01bd932 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 26 Nov 2024 23:32:01 -0800 Subject: [PATCH 22/66] Late night wireframe --- src/fn/upload-docs.js | 47 +++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 3f78c54f3..33a5c3b47 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -22,22 +22,27 @@ const execute = async () => { return Promise.resolve(); } - const filesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION)); - const docIdErrors = getErrorsWhereDocIdDiffersFromFilename(filesToUpload); - if (docIdErrors.length > 0) { - throw new Error(`upload-docs: ${docIdErrors.join('\n')}`); - } - - const totalCount = filesToUpload.length; + const filenamesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION)); + const totalCount = filenamesToUpload.length; if (totalCount === 0) { return; // nothing to upload } + const analysis = preuploadAnalysis(filenamesToUpload); + const errors = analysis.map(result => result.error).filter(Boolean); + if (errors.length > 0) { + throw new Error(`upload-docs: ${errors.join('\n')}`); + } + warn(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`); if (!userPrompt.keyInYN()) { throw new Error('User aborted execution.'); } + // if feature flag is on + const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); + await disableUsersAtDeletedFacilities(deletedDocIds); + const results = { ok:[], failed:{} }; const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null; const processNextBatch = async (docFiles, batchSize) => { @@ -93,20 +98,40 @@ const execute = async () => { } }; - return processNextBatch(filesToUpload, INITIAL_BATCH_SIZE); + return processNextBatch(filenamesToUpload, INITIAL_BATCH_SIZE); }; -const getErrorsWhereDocIdDiffersFromFilename = filePaths => +const preuploadAnalysis = filePaths => filePaths .map(filePath => { const json = fs.readJson(filePath); const idFromFilename = path.basename(filePath, FILE_EXTENSION); if (json._id !== idFromFilename) { - return `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.`; + return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` }; + } + + if (json._delete) { + return { delete: json._id }; } }) - .filter(err => err); + .filter(Boolean); + +const updateUsersAtDeletedFacilities = deletedDocIds => { + // const urls = deletedDocIds.map(id => `/api/v2/users?facility_id=${id}`); + // make api request per deleted document + // how can we know which ids are worth querying? what about when we have delete-contacts and delete 10000 places? + // store map of id -> userdoc and id -> [facility_ids] because multiple docs per facility and multiple facilities being deleted affecting same user + + // prompt to disable the list of usernames? + + // remove all facility_ids + // if it is an array, remove the facility_id + + // update each userdoc + // if the array is not empty, update the user via POST /username + // if the array is empty or it was not an array, disable the use via DELETE /username +}; module.exports = { requiresInstance: true, From 8b751b66128f9432e06e2957afa57e40285f6838 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 28 Nov 2024 10:02:46 -0800 Subject: [PATCH 23/66] Passing with automation --- src/fn/upload-docs.js | 70 +++++++++++++----- src/lib/api.js | 24 +++++++ test/fn/upload-docs.spec.js | 137 +++++++++++++++++++++++++++++++----- 3 files changed, 197 insertions(+), 34 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 33a5c3b47..0e37fdd52 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -1,12 +1,13 @@ const path = require('path'); const minimist = require('minimist'); -const userPrompt = require('../lib/user-prompt'); +const api = require('../lib/api'); const environment = require('../lib/environment'); const fs = require('../lib/sync-fs'); const log = require('../lib/log'); const pouch = require('../lib/db'); const progressBar = require('../lib/progress-bar'); +const userPrompt = require('../lib/user-prompt'); const { info, trace, warn } = log; @@ -39,9 +40,10 @@ const execute = async () => { throw new Error('User aborted execution.'); } - // if feature flag is on - const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); - await disableUsersAtDeletedFacilities(deletedDocIds); + if (args['disable-users']) { + const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); + await handleUsersAtDeletedFacilities(deletedDocIds); + } const results = { ok:[], failed:{} }; const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null; @@ -111,26 +113,62 @@ const preuploadAnalysis = filePaths => return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` }; } - if (json._delete) { + if (json._deleted) { return { delete: json._id }; } }) .filter(Boolean); -const updateUsersAtDeletedFacilities = deletedDocIds => { - // const urls = deletedDocIds.map(id => `/api/v2/users?facility_id=${id}`); - // make api request per deleted document +const handleUsersAtDeletedFacilities = async deletedDocIds => { // how can we know which ids are worth querying? what about when we have delete-contacts and delete 10000 places? - // store map of id -> userdoc and id -> [facility_ids] because multiple docs per facility and multiple facilities being deleted affecting same user + + const affectedUsers = await getAffectedUsers(); + const usernames = affectedUsers.map(userDoc => userDoc.name).join(', '); + warn(`This operation will disable ${affectedUsers.length} user accounts: ${usernames} Are you sure you want to continue?`); + if (!userPrompt.keyInYN()) { + return; + } - // prompt to disable the list of usernames? + await updateAffectedUsers(); - // remove all facility_ids - // if it is an array, remove the facility_id - - // update each userdoc - // if the array is not empty, update the user via POST /username - // if the array is empty or it was not an array, disable the use via DELETE /username + async function getAffectedUsers() { + const knownUserDocs = {}; + for (const facilityId of deletedDocIds) { + const fetchedUserDocs = await api().getUsersAtPlace(facilityId); + for (const fetchedUserDoc of fetchedUserDocs) { + const userDoc = knownUserDocs[fetchedUserDoc.name] || fetchedUserDoc; + removeFacility(userDoc, facilityId); + knownUserDocs[userDoc.name] = userDoc; + } + } + + return Object.values(knownUserDocs); + } + + function removeFacility(userDoc, facilityId) { + if (Array.isArray(userDoc.facility_id)) { + userDoc.facility_id = userDoc.facility_id + .filter(id => id !== facilityId); + } else { + delete userDoc.facility_id; + } + } + + async function updateAffectedUsers() { + let disabledUsers = 0, updatedUsers = 0; + for (const userDoc of affectedUsers) { + const shouldDisable = !userDoc.facility_id || userDoc.facility_id?.length === 0; + if (shouldDisable) { + await api().disableUser(userDoc.name); + disabledUsers++; + } else { + await api().updateUser(userDoc); + updatedUsers++; + } + } + + info(`${disabledUsers} users disabled. ${updatedUsers} users updated.`); + } }; module.exports = { diff --git a/src/lib/api.js b/src/lib/api.js index 58b2050ca..a3fdb9f10 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -39,6 +39,7 @@ const request = { get: _request('get'), post: _request('post'), put: _request('put'), + delete: _request('delete'), }; const logDeprecatedTransitions = (settings) => { @@ -98,6 +99,29 @@ const api = { .then(() => updateAppSettings(content)); }, + async getUsersAtPlace(facilityId) { + const result = await request.get({ + uri: `${environment.instanceUrl}/api/v2/users?facility_id=${facilityId}`, + json: true, + }); + + return result?.rows || []; + }, + + disableUser(username) { + return request.delete({ + uri: `${environment.instanceUrl}/api/v2/users/${username}`, + }); + }, + + updateUser(userDoc) { + return request.post({ + uri: `${environment.instanceUrl}/api/v2/users/${userDoc.name}`, + json: true, + body: userDoc, + }); + }, + createUser(userData) { return request.post({ uri: `${environment.instanceUrl}/api/v1/users`, diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index dba4a02f6..2af2072d5 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -2,7 +2,7 @@ const { expect, assert } = require('chai'); const rewire = require('rewire'); const sinon = require('sinon'); -const api = require('../api-stub'); +const apiStub = require('../api-stub'); const environment = require('../../src/lib/environment'); let uploadDocs = rewire('../../src/fn/upload-docs'); const userPrompt = rewire('../../src/lib/user-prompt'); @@ -10,32 +10,40 @@ let readLine = { keyInYN: () => true }; userPrompt.__set__('readline', readLine); uploadDocs.__set__('userPrompt', userPrompt); -describe('upload-docs', function() { - let fs; +let fs, expectedDocs; +describe('upload-docs', function() { beforeEach(() => { sinon.stub(environment, 'isArchiveMode').get(() => false); sinon.stub(environment, 'extraArgs').get(() => undefined); sinon.stub(environment, 'pathToProject').get(() => '.'); sinon.stub(environment, 'force').get(() => false); - api.start(); + apiStub.start(); + expectedDocs = [ + { _id: 'one' }, + { _id: 'two' }, + { _id: 'three' }, + ]; fs = { exists: () => true, - recurseFiles: () => ['one.doc.json', 'two.doc.json', 'three.doc.json'], + recurseFiles: () => expectedDocs.map(doc => `${doc._id}.doc.json`), writeJson: () => {}, - readJson: name => ({ _id: name.substring(0, name.length - '.doc.json'.length) }), + readJson: name => { + const id = name.substring(0, name.length - '.doc.json'.length); + return expectedDocs.find(doc => doc._id === id); + }, }; uploadDocs.__set__('fs', fs); }); afterEach(() => { sinon.restore(); - return api.stop(); + return apiStub.stop(); }); it('should upload docs to pouch', async () => { await assertDbEmpty(); await uploadDocs.execute(); - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); @@ -71,19 +79,17 @@ describe('upload-docs', function() { const bulkDocs = sinon.stub() .onCall(0).throws({ error: 'timeout' }) .returns(Promise.resolve([{}])); - fs.recurseFiles = () => new Array(10).fill('').map((x, i) => `${i}.doc.json`); + expectedDocs = new Array(10).fill('').map((x, i) => ({ _id: i.toString() })); const clock = sinon.useFakeTimers(0); - uploadDocs = rewire('../../src/fn/upload-docs'); - uploadDocs.__set__('userPrompt', userPrompt); - - const imported_date = new Date(0).toISOString(); + const imported_date = new Date().toISOString(); return uploadDocs.__with__({ INITIAL_BATCH_SIZE: 4, + Date, fs, pouch: () => ({ bulkDocs }), })(async () => { await uploadDocs.execute(); - expect(bulkDocs.callCount).to.eq(1 + 10 / 2); + expect(bulkDocs.callCount).to.eq(6); // first failed batch of 4 expect(bulkDocs.args[0][0]).to.deep.eq([ @@ -106,8 +112,6 @@ describe('upload-docs', function() { ]); clock.restore(); - uploadDocs = rewire('../../src/fn/upload-docs'); - uploadDocs.__set__('userPrompt', userPrompt); }); }); @@ -128,12 +132,109 @@ describe('upload-docs', function() { await assertDbEmpty(); sinon.stub(process, 'exit'); await uploadDocs.execute(); - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); + + describe('kenn --disable-users', () => { + beforeEach(async () => { + sinon.stub(environment, 'extraArgs').get(() => ['--disable-users']); + await assertDbEmpty(); + }); + + it('user with single facility_id gets deleted', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ _id: 'org.couchdb.user:user1', name: 'user1', facility_id: 'one' }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, + ]); + }); + + it('user with multiple facility_ids gets updated', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one', 'two'] }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + const expectedBody = { + _id: 'org.couchdb.user:user1', + name: 'user1', + facility_id: [ 'two' ], + }; + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'POST', url: '/api/v2/users/user1', body: expectedBody }, + ]); + }); + + it('user with multiple facility_ids gets deleted', async () => { + await setupDeletedFacilities('one', 'two'); + const user1Doc = { _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one', 'two'] }; + setupApiResponses(1, [user1Doc], [user1Doc]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=two', body: {} }, + { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, + ]); + }); + + it('two users disabled when single facility_id has multiple users', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(2, [ + { _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one'] }, + { _id: 'org.couchdb.user:user2', name: 'user2', facility_id: ['one', 'two'] } + ]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + const expectedUser2 = { + _id: 'org.couchdb.user:user2', + name: 'user2', + facility_id: ['two'], + } + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, + { method: 'POST', url: '/api/v2/users/user2', body: expectedUser2 }, + ]); + }); + }); }); +function setupApiResponses(writeCount, ...userDocResponseRows) { + const responseBodies = userDocResponseRows.map(rows => ({ body: { rows } })); + const writeResponses = new Array(writeCount).fill({ status: 200 }); + apiStub.giveResponses( + ...responseBodies, + ...writeResponses, + ); +} + +async function setupDeletedFacilities(...docIds) { + for (const id of docIds) { + const writtenDoc = await apiStub.db.put({ _id: id }); + const expected = expectedDocs.find(doc => doc._id === id); + expected._rev = writtenDoc.rev; + expected._deleted = true; + } +} + async function assertDbEmpty() { - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows).to.be.empty; } From ad0927268655c518b0eed064f281dc0e4430335b Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 28 Nov 2024 20:38:20 -0800 Subject: [PATCH 24/66] After testing --- src/fn/upload-docs.js | 42 +++++++++++++++++++++++------------- src/lib/api.js | 6 +++--- test/fn/upload-docs.spec.js | 43 +++++++++++++++++++++---------------- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 0e37fdd52..8f1dba0e5 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const path = require('path'); const minimist = require('minimist'); @@ -123,9 +124,9 @@ const handleUsersAtDeletedFacilities = async deletedDocIds => { // how can we know which ids are worth querying? what about when we have delete-contacts and delete 10000 places? const affectedUsers = await getAffectedUsers(); - const usernames = affectedUsers.map(userDoc => userDoc.name).join(', '); - warn(`This operation will disable ${affectedUsers.length} user accounts: ${usernames} Are you sure you want to continue?`); - if (!userPrompt.keyInYN()) { + const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); + warn(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`); + if (affectedUsers.length === 0 || !userPrompt.keyInYN()) { return; } @@ -134,34 +135,45 @@ const handleUsersAtDeletedFacilities = async deletedDocIds => { async function getAffectedUsers() { const knownUserDocs = {}; for (const facilityId of deletedDocIds) { - const fetchedUserDocs = await api().getUsersAtPlace(facilityId); - for (const fetchedUserDoc of fetchedUserDocs) { - const userDoc = knownUserDocs[fetchedUserDoc.name] || fetchedUserDoc; - removeFacility(userDoc, facilityId); - knownUserDocs[userDoc.name] = userDoc; + const fetchedUserInfos = await api().getUsersAtPlace(facilityId); + for (const fetchedUserInfo of fetchedUserInfos) { + const userDoc = knownUserDocs[fetchedUserInfo.username] || toPostApiFormat(fetchedUserInfo); + removePlace(userDoc, facilityId); + knownUserDocs[userDoc.username] = userDoc; } } return Object.values(knownUserDocs); } - function removeFacility(userDoc, facilityId) { - if (Array.isArray(userDoc.facility_id)) { - userDoc.facility_id = userDoc.facility_id - .filter(id => id !== facilityId); + function toPostApiFormat(apiResponse) { + return { + _id: apiResponse.id, + _rev: apiResponse.rev, + username: apiResponse.username, + place: apiResponse.place?.filter(Boolean).map(place => place._id), + }; + } + + function removePlace(userDoc, placeId) { + if (Array.isArray(userDoc.place)) { + userDoc.place = userDoc.place + .filter(id => id !== placeId); } else { - delete userDoc.facility_id; + delete userDoc.place; } } async function updateAffectedUsers() { let disabledUsers = 0, updatedUsers = 0; for (const userDoc of affectedUsers) { - const shouldDisable = !userDoc.facility_id || userDoc.facility_id?.length === 0; + const shouldDisable = !userDoc.place || userDoc.place?.length === 0; if (shouldDisable) { - await api().disableUser(userDoc.name); + trace(`Disabling ${userDoc.username}`); + await api().disableUser(userDoc.username); disabledUsers++; } else { + trace(`Updating ${userDoc.username}`); await api().updateUser(userDoc); updatedUsers++; } diff --git a/src/lib/api.js b/src/lib/api.js index a3fdb9f10..e11a96a8a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -105,18 +105,18 @@ const api = { json: true, }); - return result?.rows || []; + return result || []; }, disableUser(username) { return request.delete({ - uri: `${environment.instanceUrl}/api/v2/users/${username}`, + uri: `${environment.instanceUrl}/api/v1/users/${username}`, }); }, updateUser(userDoc) { return request.post({ - uri: `${environment.instanceUrl}/api/v2/users/${userDoc.name}`, + uri: `${environment.instanceUrl}/api/v1/users/${userDoc.username}`, json: true, body: userDoc, }); diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 2af2072d5..c4127b297 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -142,9 +142,14 @@ describe('upload-docs', function() { await assertDbEmpty(); }); - it('user with single facility_id gets deleted', async () => { + const twoPlaces = [ + { _id: 'one' }, + { _id: 'two' }, + ]; + + it('user with single place gets deleted', async () => { await setupDeletedFacilities('one'); - setupApiResponses(1, [{ _id: 'org.couchdb.user:user1', name: 'user1', facility_id: 'one' }]); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: [{ _id: 'one' }] }]); await uploadDocs.execute(); const res = await apiStub.db.allDocs(); @@ -152,13 +157,13 @@ describe('upload-docs', function() { assert.deepEqual(apiStub.requestLog(), [ { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, - { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, ]); }); - it('user with multiple facility_ids gets updated', async () => { + it('user with multiple places gets updated', async () => { await setupDeletedFacilities('one'); - setupApiResponses(1, [{ _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one', 'two'] }]); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }]); await uploadDocs.execute(); const res = await apiStub.db.allDocs(); @@ -166,18 +171,18 @@ describe('upload-docs', function() { const expectedBody = { _id: 'org.couchdb.user:user1', - name: 'user1', - facility_id: [ 'two' ], + username: 'user1', + place: [ 'two' ], }; assert.deepEqual(apiStub.requestLog(), [ { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, - { method: 'POST', url: '/api/v2/users/user1', body: expectedBody }, + { method: 'POST', url: '/api/v1/users/user1', body: expectedBody }, ]); }); - it('user with multiple facility_ids gets deleted', async () => { + it('user with multiple places gets deleted', async () => { await setupDeletedFacilities('one', 'two'); - const user1Doc = { _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one', 'two'] }; + const user1Doc = { id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }; setupApiResponses(1, [user1Doc], [user1Doc]); await uploadDocs.execute(); @@ -187,15 +192,15 @@ describe('upload-docs', function() { assert.deepEqual(apiStub.requestLog(), [ { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=two', body: {} }, - { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, ]); }); - it('two users disabled when single facility_id has multiple users', async () => { + it('two users disabled when single place has multiple users', async () => { await setupDeletedFacilities('one'); setupApiResponses(2, [ - { _id: 'org.couchdb.user:user1', name: 'user1', facility_id: ['one'] }, - { _id: 'org.couchdb.user:user2', name: 'user2', facility_id: ['one', 'two'] } + { id: 'org.couchdb.user:user1', username: 'user1', place: [{ _id: 'one' }] }, + { id: 'org.couchdb.user:user2', username: 'user2', place: twoPlaces } ]); await uploadDocs.execute(); @@ -204,20 +209,20 @@ describe('upload-docs', function() { const expectedUser2 = { _id: 'org.couchdb.user:user2', - name: 'user2', - facility_id: ['two'], + username: 'user2', + place: ['two'], } assert.deepEqual(apiStub.requestLog(), [ { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, - { method: 'DELETE', url: '/api/v2/users/user1', body: {} }, - { method: 'POST', url: '/api/v2/users/user2', body: expectedUser2 }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + { method: 'POST', url: '/api/v1/users/user2', body: expectedUser2 }, ]); }); }); }); function setupApiResponses(writeCount, ...userDocResponseRows) { - const responseBodies = userDocResponseRows.map(rows => ({ body: { rows } })); + const responseBodies = userDocResponseRows.map(body => ({ body })); const writeResponses = new Array(writeCount).fill({ status: 200 }); apiStub.giveResponses( ...responseBodies, From de78eb01b753af0e48354eebdea898f321e7defe Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 28 Nov 2024 20:39:37 -0800 Subject: [PATCH 25/66] Passing eslint --- src/fn/upload-docs.js | 1 - test/fn/upload-docs.spec.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 8f1dba0e5..ecb24787a 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -1,4 +1,3 @@ -const _ = require('lodash'); const path = require('path'); const minimist = require('minimist'); diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index c4127b297..44dc5cf77 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -211,7 +211,7 @@ describe('upload-docs', function() { _id: 'org.couchdb.user:user2', username: 'user2', place: ['two'], - } + }; assert.deepEqual(apiStub.requestLog(), [ { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, From 6d0cc3e272dee9c17cf47e3bef0814b905e20e84 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 5 Dec 2024 19:14:52 -0800 Subject: [PATCH 26/66] Reduced nesting via curried function --- src/lib/hierarchy-operations/index.js | 237 +++++++++++++------------- 1 file changed, 122 insertions(+), 115 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 19b6b861b..a43390ec6 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -5,8 +5,8 @@ const { trace, info } = require('../log'); const JsDocs = require('./jsdocFolder'); const Backend = require('./backend'); -const HierarchyOperations = (db, options) => { - async function move(sourceIds, destinationId) { +function moveHierarchy(db, options) { + return async function (sourceIds, destinationId) { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); @@ -19,6 +19,12 @@ const HierarchyOperations = (db, options) => { for (let sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; const descendantsAndSelf = await Backend.descendantsOf(db, sourceId); + const moveContext = { + sourceId, + destinationId, + descendantsAndSelf, + replacementLineage, + }; if (options.merge) { const self = descendantsAndSelf.find(d => d._id === sourceId); @@ -37,15 +43,15 @@ const HierarchyOperations = (db, options) => { } trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, sourceId); + const updatedDescendants = replaceLineageInContacts(options, moveContext); const ancestors = await Backend.ancestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); - minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors]); + minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]); - const movedReportsCount = await moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId); + const movedReportsCount = await moveReports(db, options, moveContext, destinationId); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -56,138 +62,139 @@ const HierarchyOperations = (db, options) => { info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); } +} - async function moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId) { - const descendantIds = descendantsAndSelf.map(contact => contact._id); - - let skip = 0; - let reportDocsBatch; - do { - info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); - const createdAtId = options.merge && sourceId; - reportDocsBatch = await Backend.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); +async function moveReports(db, options, moveContext) { + const descendantIds = moveContext.descendantsAndSelf.map(contact => contact._id); - const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); + let skip = 0; + let reportDocsBatch; + do { + info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); + const createdAtId = options.merge && moveContext.sourceId; + reportDocsBatch = await Backend.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); - if (options.merge) { - reassignReports(reportDocsBatch, sourceId, destinationId, updatedReports); - } + const updatedReports = replaceLineageInReports(options, reportDocsBatch, moveContext); - minifyLineageAndWriteToDisk(updatedReports); + if (options.merge) { + reassignReports(reportDocsBatch, moveContext, updatedReports); + } - skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= Backend.BATCH_SIZE); + minifyLineageAndWriteToDisk(options, updatedReports); - return skip; - } + skip += reportDocsBatch.length; + } while (reportDocsBatch.length >= Backend.BATCH_SIZE); - function reassignReports(reports, sourceId, destinationId, updatedReports) { - function reassignReportWithSubject(report, subjectId) { - let updated = false; - if (report[subjectId] === sourceId) { - report[subjectId] = destinationId; - updated = true; - } + return skip; +} - if (report.fields[subjectId] === sourceId) { - report.fields[subjectId] = destinationId; - updated = true; - } +function reassignReports(reports, { sourceId, destinationId }, updatedReports) { + function reassignReportWithSubject(report, subjectId) { + let updated = false; + if (report[subjectId] === sourceId) { + report[subjectId] = destinationId; + updated = true; + } - if (updated) { - const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); - if (!isAlreadyUpdated) { - updatedReports.push(report); - } - } + if (report.fields[subjectId] === sourceId) { + report.fields[subjectId] = destinationId; + updated = true; } - for (const report of reports) { - const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; - for (const subjectId of subjectIds) { - reassignReportWithSubject(report, subjectId); + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); } } } - function minifyLineageAndWriteToDisk(docs) { - docs.forEach(doc => { - lineageManipulation.minifyLineagesInDoc(doc); - JsDocs.writeDoc(options, doc); - }); + for (const report of reports) { + const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + for (const subjectId of subjectIds) { + reassignReportWithSubject(report, subjectId); + } } - - function replaceLineageInReports(reportsCreatedByDescendants, replaceWith, startingFromId) { - return reportsCreatedByDescendants.reduce((agg, doc) => { - const replaceLineageOptions = { - lineageAttribute: 'contact', - replaceWith, - startingFromId, - merge: options.merge, - }; - - if (lineageManipulation.replaceLineage(doc, replaceLineageOptions)) { - agg.push(doc); - } - return agg; - }, []); +} + +function minifyLineageAndWriteToDisk(options, docs) { + docs.forEach(doc => { + lineageManipulation.minifyLineagesInDoc(doc); + JsDocs.writeDoc(options, doc); + }); +} + +function replaceLineageInReports(options, reportsCreatedByDescendants, moveContext) { + return reportsCreatedByDescendants.reduce((agg, doc) => { + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith: moveContext.replacementLineage, + startingFromId: moveContext.sourceId, + merge: options.merge, + }; + + if (lineageManipulation.replaceLineage(doc, replaceLineageOptions)) { + agg.push(doc); + } + return agg; + }, []); +} + +function replaceLineageInAncestors(descendantsAndSelf, ancestors) { + return ancestors.reduce((agg, ancestor) => { + let result = agg; + const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + if (primaryContact) { + ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); + result = [ancestor, ...result]; + } + + return result; + }, []); +} + +function replaceLineageInContacts(options, moveContext) { + const { sourceId } = moveContext; + function replaceForSingleContact(doc) { + const docIsDestination = doc._id === sourceId; + const startingFromId = options.merge || !docIsDestination ? sourceId : undefined; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: moveContext.replacementLineage, + startingFromId, + merge: options.merge, + }; + const parentWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + + replaceLineageOptions.lineageAttribute = 'contact'; + replaceLineageOptions.startingFromId = sourceId; + const contactWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + const isUpdated = parentWasUpdated || contactWasUpdated; + if (isUpdated) { + result.push(doc); + } } - function replaceLineageInAncestors(descendantsAndSelf, ancestors) { - return ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); - if (primaryContact) { - ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; - } + const result = []; + for (const doc of moveContext.descendantsAndSelf) { + const docIsDestination = doc._id === sourceId; - return result; - }, []); - } - - function replaceLineageInContacts(descendantsAndSelf, replaceWith, destinationId) { - function replaceForSingleContact(doc) { - const docIsDestination = doc._id === destinationId; - const startingFromId = options.merge || !docIsDestination ? destinationId : undefined; - const replaceLineageOptions = { - lineageAttribute: 'parent', - replaceWith, - startingFromId, - merge: options.merge, - }; - const parentWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); - - replaceLineageOptions.lineageAttribute = 'contact'; - replaceLineageOptions.startingFromId = destinationId; - const contactWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); - const isUpdated = parentWasUpdated || contactWasUpdated; - if (isUpdated) { - result.push(doc); - } + // skip top-level because it will be deleted + if (options.merge && docIsDestination) { + continue; } - const result = []; - for (const doc of descendantsAndSelf) { - const docIsDestination = doc._id === destinationId; - - // skip top-level because it will be deleted - if (options.merge && docIsDestination) { - continue; - } - - replaceForSingleContact(doc); - } - - return result; + replaceForSingleContact(doc); } - return { move }; -}; + return result; +} -module.exports = (db, options) => ({ - HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, - move: HierarchyOperations(db, { ...options, merge: false }).move, - merge: HierarchyOperations(db, { ...options, merge: true }).move, -}); +module.exports = (db, options) => { + return { + HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, + move: moveHierarchy(db, { ...options, merge: false }), + merge: moveHierarchy(db, { ...options, merge: true }), + }; +}; From e561431050f09b498d10ca26d3084ab26a757b14 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 5 Dec 2024 19:22:35 -0800 Subject: [PATCH 27/66] 4 feedbacks --- src/lib/hierarchy-operations/jsdocFolder.js | 6 +++--- src/lib/hierarchy-operations/lineage-constraints.js | 2 +- src/lib/hierarchy-operations/lineage-manipulation.js | 8 +++----- test/lib/hierarchy-operations/lineage-constraints.spec.js | 8 ++++---- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js index a45836c4d..b24358acb 100644 --- a/src/lib/hierarchy-operations/jsdocFolder.js +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -23,11 +23,11 @@ function writeDoc({ docDirectoryPath }, doc) { function deleteAfterConfirmation(docDirectoryPath) { warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); - if (userPrompt.keyInYN()) { - fs.deleteFilesInFolder(docDirectoryPath); - } else { + if (!userPrompt.keyInYN()) { throw new Error('User aborted execution.'); } + + fs.deleteFilesInFolder(docDirectoryPath); } module.exports = { diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 5bfbae19f..8d8cf726a 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -3,7 +3,7 @@ const { trace } = log; const lineageManipulation = require('./lineage-manipulation'); -module.exports = async (db, options = {}) => { +module.exports = async (db, options) => { const mapTypeToAllowedParents = await fetchAllowedParents(db); const getHierarchyErrors = (sourceDoc, destinationDoc) => { diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index f966e330c..11759ad96 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -73,19 +73,17 @@ Function borrowed from shared-lib/lineage const minifyLineagesInDoc = doc => { const minifyLineage = lineage => { if (!lineage?._id) { - return undefined; + return; } - const result = { + return { _id: lineage._id, parent: minifyLineage(lineage.parent), }; - - return result; }; if (!doc) { - return undefined; + return; } if ('parent' in doc) { diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index d4812d115..6c37d05cb 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -36,21 +36,21 @@ describe('lineage constriants', () => { it('no settings doc requires valid parent type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb); + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); const actual = getHierarchyErrors({ type: 'person' }, { type: 'dne' }); expect(actual).to.include('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb); + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); const actual = getHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); expect(actual).to.include('unknown type'); }); it('no settings doc yields not defined', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb); + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); const actual = getHierarchyErrors({ type: 'person' }, { type: 'clinic' }); expect(actual).to.be.undefined; }); @@ -66,7 +66,7 @@ describe('lineage constriants', () => { it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb); + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); const actual = getHierarchyErrors({ type: 'district_hospital' }, undefined); expect(actual).to.be.undefined; }); From 92ae09453dc5c30dc929fe5a83cf0a63dd660319 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 5 Dec 2024 19:47:37 -0800 Subject: [PATCH 28/66] Remove getHierarchyErrors public interface --- .../{backend.js => hierarchy-data-source.js} | 14 ++-- src/lib/hierarchy-operations/index.js | 18 ++--- .../lineage-constraints.js | 43 +++++------ .../hierarchy-operations.spec.js | 18 ++--- .../lineage-constraints.spec.js | 74 ++++++++++--------- 5 files changed, 87 insertions(+), 80 deletions(-) rename src/lib/hierarchy-operations/{backend.js => hierarchy-data-source.js} (90%) diff --git a/src/lib/hierarchy-operations/backend.js b/src/lib/hierarchy-operations/hierarchy-data-source.js similarity index 90% rename from src/lib/hierarchy-operations/backend.js rename to src/lib/hierarchy-operations/hierarchy-data-source.js index 30990d8b3..33174bc96 100644 --- a/src/lib/hierarchy-operations/backend.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -7,7 +7,7 @@ const BATCH_SIZE = 10000; /* Fetches all of the documents associated with the "contactIds" and confirms they exist. */ -async function contactList(db, ids) { +async function getContactsByIds(db, ids) { const contactDocs = await db.allDocs({ keys: ids, include_docs: true, @@ -18,10 +18,12 @@ async function contactList(db, ids) { throw Error(missingContactErrors); } - return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); + const contactDict = {}; + contactDocs.rows.forEach(({ doc }) => contactDict[doc._id] = doc); + return contactDict; } -async function contact(db, id) { +async function getContact(db, id) { try { if (id === HIERARCHY_ROOT) { return undefined; @@ -29,7 +31,7 @@ async function contact(db, id) { return await db.get(id); } catch (err) { - if (err.name !== 'not_found') { + if (err.status !== 404) { throw err; } @@ -94,7 +96,7 @@ module.exports = { BATCH_SIZE, ancestorsOf, descendantsOf, - contact, - contactList, + getContact, + getContactsByIds, reportsCreatedByOrAt, }; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index a43390ec6..6c72df57f 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -3,22 +3,22 @@ const LineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); const JsDocs = require('./jsdocFolder'); -const Backend = require('./backend'); +const DataSource = require('./hierarchy-data-source'); function moveHierarchy(db, options) { return async function (sourceIds, destinationId) { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); const constraints = await LineageConstraints(db, options); - const destinationDoc = await Backend.contact(db, destinationId); - const sourceDocs = await Backend.contactList(db, sourceIds); + const destinationDoc = await DataSource.getContact(db, destinationId); + const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); for (let sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; - const descendantsAndSelf = await Backend.descendantsOf(db, sourceId); + const descendantsAndSelf = await DataSource.descendantsOf(db, sourceId); const moveContext = { sourceId, destinationId, @@ -45,7 +45,7 @@ function moveHierarchy(db, options) { trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedDescendants = replaceLineageInContacts(options, moveContext); - const ancestors = await Backend.ancestorsOf(db, sourceDoc); + const ancestors = await DataSource.ancestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); @@ -70,9 +70,9 @@ async function moveReports(db, options, moveContext) { let skip = 0; let reportDocsBatch; do { - info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); + info(`Processing ${skip} to ${skip + DataSource.BATCH_SIZE} report docs`); const createdAtId = options.merge && moveContext.sourceId; - reportDocsBatch = await Backend.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + reportDocsBatch = await DataSource.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); const updatedReports = replaceLineageInReports(options, reportDocsBatch, moveContext); @@ -83,7 +83,7 @@ async function moveReports(db, options, moveContext) { minifyLineageAndWriteToDisk(options, updatedReports); skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= Backend.BATCH_SIZE); + } while (reportDocsBatch.length >= DataSource.BATCH_SIZE); return skip; } @@ -192,7 +192,7 @@ function replaceLineageInContacts(options, moveContext) { module.exports = (db, options) => { return { - HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, + HIERARCHY_ROOT: DataSource.HIERARCHY_ROOT, move: moveHierarchy(db, { ...options, merge: false }), merge: moveHierarchy(db, { ...options, merge: true }), }; diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 8d8cf726a..1bdf4e2b3 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -6,20 +6,18 @@ const lineageManipulation = require('./lineage-manipulation'); module.exports = async (db, options) => { const mapTypeToAllowedParents = await fetchAllowedParents(db); - const getHierarchyErrors = (sourceDoc, destinationDoc) => { - if (options.merge) { - return getMergeViolations(sourceDoc, destinationDoc); - } - - return getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc); - }; - return { getPrimaryContactViolations: (sourceDoc, destinationDoc, descendantDocs) => getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs), - getHierarchyErrors, assertHierarchyErrors: (sourceDocs, destinationDoc) => { + if (!Array.isArray(sourceDocs)) { + sourceDocs = [sourceDocs]; + } + sourceDocs.forEach(sourceDoc => { - const hierarchyError = getHierarchyErrors(sourceDoc, destinationDoc); + const hierarchyError = options.merge ? + getMergeViolations(sourceDoc, destinationDoc) + : getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc); + if (hierarchyError) { throw Error(`Hierarchy Constraints: ${hierarchyError}`); } @@ -62,11 +60,13 @@ const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) } function findCircularHierarchyErrors() { - if (destinationDoc && sourceDoc._id) { - const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; - if (parentAncestry.includes(sourceDoc._id)) { - return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; - } + if (!destinationDoc || !sourceDoc._id) { + return; + } + + const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; + if (parentAncestry.includes(sourceDoc._id)) { + return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; } } @@ -141,17 +141,18 @@ const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type async function fetchAllowedParents(db) { try { - const { settings } = await db.get('settings'); - const { contact_types } = settings; + const { settings: { contact_types } } = await db.get('settings'); if (Array.isArray(contact_types)) { trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.'); - return contact_types - .filter(rule => rule) - .reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {}); + const parentDict = {}; + contact_types + .filter(Boolean) + .forEach(({ id, parents }) => parentDict[id] = parents); + return parentDict; } } catch (err) { - if (err.name !== 'not_found') { + if (err.status !== 404) { throw err; } } diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 3d6eee0ab..c18cd0250 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -5,7 +5,7 @@ const sinon = require('sinon'); const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder.js'); -const Backend = rewire('../../../src/lib/hierarchy-operations/backend.js'); +const DataSource = rewire('../../../src/lib/hierarchy-operations/hierarchy-data-source.js'); const PouchDB = require('pouchdb-core'); @@ -17,7 +17,7 @@ const { assert, expect } = chai; const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); HierarchyOperations.__set__('JsDocs', JsDocs); -HierarchyOperations.__set__('Backend', Backend); +HierarchyOperations.__set__('DataSource', DataSource); const contacts_by_depth = { // eslint-disable-next-line quotes @@ -588,7 +588,7 @@ describe('move-contacts', () => { }); describe('batching works as expected', () => { - const initialBatchSize = Backend.BATCH_SIZE; + const initialBatchSize = DataSource.BATCH_SIZE; beforeEach(async () => { await mockReport(pouchDb, { id: 'report_2', @@ -607,13 +607,13 @@ describe('move-contacts', () => { }); afterEach(() => { - Backend.BATCH_SIZE = initialBatchSize; - Backend.__set__('BATCH_SIZE', initialBatchSize); + DataSource.BATCH_SIZE = initialBatchSize; + DataSource.__set__('BATCH_SIZE', initialBatchSize); }); it('move health_center_1 to district_2 in batches of 1', async () => { - Backend.__set__('BATCH_SIZE', 1); - Backend.BATCH_SIZE = 1; + DataSource.__set__('BATCH_SIZE', 1); + DataSource.BATCH_SIZE = 1; sinon.spy(pouchDb, 'query'); await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); @@ -688,8 +688,8 @@ describe('move-contacts', () => { }); it('should health_center_1 to district_1 in batches of 2', async () => { - Backend.__set__('BATCH_SIZE', 2); - Backend.BATCH_SIZE = 2; + DataSource.__set__('BATCH_SIZE', 2); + DataSource.BATCH_SIZE = 2; sinon.spy(pouchDb, 'query'); await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index 6c37d05cb..f13bb73e5 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -1,8 +1,12 @@ -const { expect } = require('chai'); -const rewire = require('rewire'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); +const rewire = require('rewire'); + +chai.use(chaiAsPromised); +const { expect } = chai; const { mockHierarchy } = require('../../mock-hierarchies'); @@ -11,70 +15,70 @@ const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { - describe('getHierarchyErrors', () => { - it('empty rules yields error', async () => expect(await runScenario([], 'person', 'health_center')).to.include('unknown type')); + describe('assertHierarchyErrors', () => { + it('empty rules yields error', async () => await expect(runScenario([], 'person', 'health_center')).to.eventually.rejectedWith('unknown type')); - it('no valid parent yields error', async () => expect(await runScenario([undefined], 'person', 'health_center')).to.include('unknown type')); + it('no valid parent yields error', async () => await expect(runScenario([undefined], 'person', 'health_center')).to.eventually.rejectedWith('unknown type')); it('valid parent yields no error', async () => { - const actual = await runScenario([{ + const actual = runScenario([{ id: 'person', parents: ['health_center'], }], 'person', 'health_center'); - expect(actual).to.be.undefined; + await expect(actual).to.eventually.equal(undefined); }); - it('no contact type yields undefined error', async () => expect(await runScenario([])).to.include('undefined')); + it('no contact type yields undefined error', async () => expect(runScenario([])).to.eventually.rejectedWith('undefined')); - it('no parent type yields undefined error', async () => expect(await runScenario([], 'person')).to.include('undefined')); + it('no parent type yields undefined error', async () => expect(runScenario([], 'person')).to.eventually.rejectedWith('undefined')); - it('no valid parents yields not defined', async () => expect(await runScenario([{ + it('no valid parents yields not defined', async () => expect(runScenario([{ id: 'person', parents: ['district_hospital'], - }], 'person', 'health_center')).to.include('cannot have parent of type')); + }], 'person', 'health_center')).to.eventually.rejectedWith('cannot have parent of type')); it('no settings doc requires valid parent type', async () => { - const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = getHierarchyErrors({ type: 'person' }, { type: 'dne' }); - expect(actual).to.include('cannot have parent of type'); + const mockDb = { get: () => { throw { status: 404 }; } }; + const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = () => assertHierarchyErrors([{ type: 'person' }], { type: 'dne' }); + expect(actual).to.throw('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { - const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = getHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); - expect(actual).to.include('unknown type'); + const mockDb = { get: () => { throw { status: 404 }; } }; + const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = () => assertHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); + expect(actual).to.throw('unknown type'); }); it('no settings doc yields not defined', async () => { - const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = getHierarchyErrors({ type: 'person' }, { type: 'clinic' }); + const mockDb = { get: () => { throw { status: 404 }; } }; + const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = assertHierarchyErrors({ type: 'person' }, { type: 'clinic' }); expect(actual).to.be.undefined; }); it('cannot merge with self', async () => { - expect(await runScenario([], 'a', 'a', true)).to.include('self'); + await expect(runScenario([], 'a', 'a', true)).to.eventually.rejectedWith('self'); }); describe('default schema', () => { - it('no defined rules enforces defaults schema', async () => expect(await runScenario(undefined, 'district_hospital', 'health_center')).to.include('cannot have parent')); + it('no defined rules enforces defaults schema', async () => await expect(runScenario(undefined, 'district_hospital', 'health_center')).to.eventually.rejectedWith('cannot have parent')); it('nominal case', async () => expect(await runScenario(undefined, 'person', 'health_center')).to.be.undefined); it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = getHierarchyErrors({ type: 'district_hospital' }, undefined); + const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = assertHierarchyErrors({ type: 'district_hospital' }, undefined); expect(actual).to.be.undefined; }); }); }); describe('getPrimaryContactViolations', () => { - const getHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); + const assertHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); describe('on memory pouchdb', async () => { let pouchDb, scenarioCount = 0; @@ -104,13 +108,13 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_2'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); it('cannot move clinic_1_contact to root', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); @@ -118,7 +122,7 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_1'); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); @@ -127,7 +131,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_1'); const descendants = await Promise.all(['health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2'].map(id => pouchDb.get(id))); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.be.undefined; }); @@ -140,7 +144,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.deep.include({ _id: 'patient_1' }); }); @@ -151,7 +155,7 @@ describe('lineage constriants', () => { contactDoc.parent._id = 'dne'; - const doc = await getHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); }); @@ -160,6 +164,6 @@ describe('lineage constriants', () => { const runScenario = async (contact_types, sourceType, destinationType, merge = false) => { const mockDb = { get: () => ({ settings: { contact_types } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge }); - return getHierarchyErrors({ type: sourceType }, { type: destinationType }); + const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge }); + return assertHierarchyErrors({ type: sourceType }, { type: destinationType }); }; From d68a294160478d108abc3997a66463c2817e6a67 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 5 Dec 2024 20:02:26 -0800 Subject: [PATCH 29/66] Lots of lineage-constraints feedback --- .../hierarchy-data-source.js | 4 +-- src/lib/hierarchy-operations/index.js | 17 ++++----- .../lineage-constraints.js | 13 ++++--- .../lineage-constraints.spec.js | 36 +++++++++---------- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index 33174bc96..4b606846b 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -42,7 +42,7 @@ async function getContact(db, id) { /* Given a contact's id, obtain the documents of all descendant contacts */ -async function descendantsOf(db, contactId) { +async function getContactWithDescendants(db, contactId) { const descendantDocs = await db.query('medic/contacts_by_depth', { key: [contactId], include_docs: true, @@ -95,7 +95,7 @@ module.exports = { HIERARCHY_ROOT, BATCH_SIZE, ancestorsOf, - descendantsOf, + getContactWithDescendants, getContact, getContactsByIds, reportsCreatedByOrAt, diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 6c72df57f..70f2d2b5d 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -12,13 +12,13 @@ function moveHierarchy(db, options) { const constraints = await LineageConstraints(db, options); const destinationDoc = await DataSource.getContact(db, destinationId); const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); - constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); + constraints.assertNoHierarchyErrors(Object.values(sourceDocs), destinationDoc); let affectedContactCount = 0, affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); - for (let sourceId of sourceIds) { + for (const sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; - const descendantsAndSelf = await DataSource.descendantsOf(db, sourceId); + const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId); const moveContext = { sourceId, destinationId, @@ -27,20 +27,15 @@ function moveHierarchy(db, options) { }; if (options.merge) { - const self = descendantsAndSelf.find(d => d._id === sourceId); JsDocs.writeDoc(options, { - _id: self._id, - _rev: self._rev, + _id: sourceDoc._id, + _rev: sourceDoc._rev, _deleted: true, }); } const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; - // Check that primary contact is not removed from areas where they are required - const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); - if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); - } + await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedDescendants = replaceLineageInContacts(options, moveContext); diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 1bdf4e2b3..a1000a4ac 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -7,12 +7,17 @@ module.exports = async (db, options) => { const mapTypeToAllowedParents = await fetchAllowedParents(db); return { - getPrimaryContactViolations: (sourceDoc, destinationDoc, descendantDocs) => getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs), - assertHierarchyErrors: (sourceDocs, destinationDoc) => { + assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => { + const invalidPrimaryContactDoc = await getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs); + if (invalidPrimaryContactDoc) { + throw Error(`Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the hierarchy for which they are a primary contact.`); + } + }, + assertNoHierarchyErrors: (sourceDocs, destinationDoc) => { if (!Array.isArray(sourceDocs)) { sourceDocs = [sourceDocs]; } - + sourceDocs.forEach(sourceDoc => { const hierarchyError = options.merge ? getMergeViolations(sourceDoc, destinationDoc) @@ -30,7 +35,7 @@ module.exports = async (db, options) => { const contactIds = sourceDocs.map(doc => doc._id); sourceDocs .forEach(doc => { - const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; + const parentIdsOfDoc = lineageManipulation.pluckIdsFromLineage(doc.parent); const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); if (violatingParentId) { throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index f13bb73e5..67b36b5f4 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -15,7 +15,7 @@ const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { - describe('assertHierarchyErrors', () => { + describe('assertNoHierarchyErrors', () => { it('empty rules yields error', async () => await expect(runScenario([], 'person', 'health_center')).to.eventually.rejectedWith('unknown type')); it('no valid parent yields error', async () => await expect(runScenario([undefined], 'person', 'health_center')).to.eventually.rejectedWith('unknown type')); @@ -40,22 +40,22 @@ describe('lineage constriants', () => { it('no settings doc requires valid parent type', async () => { const mockDb = { get: () => { throw { status: 404 }; } }; - const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = () => assertHierarchyErrors([{ type: 'person' }], { type: 'dne' }); + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = () => assertNoHierarchyErrors([{ type: 'person' }], { type: 'dne' }); expect(actual).to.throw('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { const mockDb = { get: () => { throw { status: 404 }; } }; - const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = () => assertHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = () => assertNoHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); expect(actual).to.throw('unknown type'); }); it('no settings doc yields not defined', async () => { const mockDb = { get: () => { throw { status: 404 }; } }; - const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = assertHierarchyErrors({ type: 'person' }, { type: 'clinic' }); + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = assertNoHierarchyErrors({ type: 'person' }, { type: 'clinic' }); expect(actual).to.be.undefined; }); @@ -70,15 +70,15 @@ describe('lineage constriants', () => { it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); - const actual = assertHierarchyErrors({ type: 'district_hospital' }, undefined); + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge: false }); + const actual = assertNoHierarchyErrors({ type: 'district_hospital' }, undefined); expect(actual).to.be.undefined; }); }); }); describe('getPrimaryContactViolations', () => { - const assertHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); + const assertNoHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); describe('on memory pouchdb', async () => { let pouchDb, scenarioCount = 0; @@ -108,13 +108,13 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_2'); - const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); it('cannot move clinic_1_contact to root', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); - const doc = await assertHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); }); @@ -122,7 +122,7 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_1'); - const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); @@ -131,7 +131,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_1'); const descendants = await Promise.all(['health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2'].map(id => pouchDb.get(id))); - const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.be.undefined; }); @@ -144,7 +144,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.deep.include({ _id: 'patient_1' }); }); @@ -155,7 +155,7 @@ describe('lineage constriants', () => { contactDoc.parent._id = 'dne'; - const doc = await assertHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); }); @@ -164,6 +164,6 @@ describe('lineage constriants', () => { const runScenario = async (contact_types, sourceType, destinationType, merge = false) => { const mockDb = { get: () => ({ settings: { contact_types } }) }; - const { assertHierarchyErrors } = await lineageConstraints(mockDb, { merge }); - return assertHierarchyErrors({ type: sourceType }, { type: destinationType }); + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge }); + return assertNoHierarchyErrors({ type: sourceType }, { type: destinationType }); }; From c964aa746764181d08bf611013d186b60df88873 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 00:46:22 -0800 Subject: [PATCH 30/66] Remove lineageAttribute --- .../hierarchy-data-source.js | 8 +- src/lib/hierarchy-operations/index.js | 44 ++++----- .../lineage-constraints.js | 11 +-- .../lineage-manipulation.js | 34 +++---- .../lineage-manipulation.spec.js | 89 +++++++++---------- 5 files changed, 89 insertions(+), 97 deletions(-) diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index 4b606846b..73baa9588 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -54,7 +54,7 @@ async function getContactWithDescendants(db, contactId) { .filter(doc => doc && doc.type !== 'tombstone'); } -async function reportsCreatedByOrAt(db, createdByIds, createdAtId, skip) { +async function getReportsForContacts(db, createdByIds, createdAtId, skip) { const createdByKeys = createdByIds.map(id => [`contact:${id}`]); const createdAtKeys = createdAtId ? [ [`patient_id:${createdAtId}`], @@ -76,7 +76,7 @@ async function reportsCreatedByOrAt(db, createdByIds, createdAtId, skip) { return _.uniqBy(reports.rows.map(row => row.doc), '_id'); } -async function ancestorsOf(db, contactDoc) { +async function getAncestorsOf(db, contactDoc) { const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); const ancestors = await db.allDocs({ keys: ancestorIds, @@ -94,9 +94,9 @@ async function ancestorsOf(db, contactDoc) { module.exports = { HIERARCHY_ROOT, BATCH_SIZE, - ancestorsOf, + getAncestorsOf, getContactWithDescendants, getContact, getContactsByIds, - reportsCreatedByOrAt, + getReportsForContacts, }; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 70f2d2b5d..7af2aaa65 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -14,7 +14,8 @@ function moveHierarchy(db, options) { const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); constraints.assertNoHierarchyErrors(Object.values(sourceDocs), destinationDoc); - let affectedContactCount = 0, affectedReportCount = 0; + let affectedContactCount = 0; + let affectedReportCount = 0; const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); for (const sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; @@ -40,7 +41,7 @@ function moveHierarchy(db, options) { trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedDescendants = replaceLineageInContacts(options, moveContext); - const ancestors = await DataSource.ancestorsOf(db, sourceDoc); + const ancestors = await DataSource.getAncestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); @@ -67,7 +68,7 @@ async function moveReports(db, options, moveContext) { do { info(`Processing ${skip} to ${skip + DataSource.BATCH_SIZE} report docs`); const createdAtId = options.merge && moveContext.sourceId; - reportDocsBatch = await DataSource.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip); const updatedReports = replaceLineageInReports(options, reportDocsBatch, moveContext); @@ -122,13 +123,12 @@ function minifyLineageAndWriteToDisk(options, docs) { function replaceLineageInReports(options, reportsCreatedByDescendants, moveContext) { return reportsCreatedByDescendants.reduce((agg, doc) => { const replaceLineageOptions = { - lineageAttribute: 'contact', replaceWith: moveContext.replacementLineage, startingFromId: moveContext.sourceId, merge: options.merge, }; - if (lineageManipulation.replaceLineage(doc, replaceLineageOptions)) { + if (lineageManipulation.replaceContactLineage(doc, replaceLineageOptions)) { agg.push(doc); } return agg; @@ -136,16 +136,16 @@ function replaceLineageInReports(options, reportsCreatedByDescendants, moveConte } function replaceLineageInAncestors(descendantsAndSelf, ancestors) { - return ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + const updatedAncestors = []; + for (const ancestor of ancestors) { + const primaryContact = descendantsAndSelf.find(descendant => descendant._id === ancestor.contact?._id); if (primaryContact) { ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; + updatedAncestors.unshift(ancestor); } - - return result; - }, []); + } + + return updatedAncestors; } function replaceLineageInContacts(options, moveContext) { @@ -154,32 +154,32 @@ function replaceLineageInContacts(options, moveContext) { const docIsDestination = doc._id === sourceId; const startingFromId = options.merge || !docIsDestination ? sourceId : undefined; const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: moveContext.replacementLineage, startingFromId, merge: options.merge, }; - const parentWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + const parentWasUpdated = lineageManipulation.replaceParentLineage(doc, replaceLineageOptions); - replaceLineageOptions.lineageAttribute = 'contact'; replaceLineageOptions.startingFromId = sourceId; - const contactWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); - const isUpdated = parentWasUpdated || contactWasUpdated; - if (isUpdated) { - result.push(doc); + const contactWasUpdated = lineageManipulation.replaceContactLineage(doc, replaceLineageOptions); + if (parentWasUpdated || contactWasUpdated) { + return doc; } } const result = []; for (const doc of moveContext.descendantsAndSelf) { - const docIsDestination = doc._id === sourceId; + const docIsSource = doc._id === sourceId; // skip top-level because it will be deleted - if (options.merge && docIsDestination) { + if (options.merge && docIsSource) { continue; } - replaceForSingleContact(doc); + const updatedDoc = replaceForSingleContact(doc); + if (updatedDoc) { + result.push(updatedDoc); + } } return result; diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index a1000a4ac..43d9ae3b7 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -120,14 +120,9 @@ A place's primary contact must be a descendant of that place. 1. Check to see which part of the contact's lineage will be removed 2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed. */ -const getPrimaryContactViolations = async (db, contactDoc, parentDoc, descendantDocs) => { - const safeGetLineageFromDoc = doc => doc ? lineageManipulation.pluckIdsFromLineage(doc.parent) : []; - const contactsLineageIds = safeGetLineageFromDoc(contactDoc); - const parentsLineageIds = safeGetLineageFromDoc(parentDoc); - - if (parentDoc) { - parentsLineageIds.push(parentDoc._id); - } +const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, descendantDocs) => { + const contactsLineageIds = lineageManipulation.pluckIdsFromLineage(contactDoc?.parent); + const parentsLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc); const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value)); const docsRemovedFromContactLineage = await db.allDocs({ diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 11759ad96..d0dbe3e1f 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -4,29 +4,28 @@ * * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing * @param {Object} params SonarQube - * @param {string} params.lineageAttribute Name of the attribute which is a lineage in doc (contact or parent) * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced */ -function replaceLineage(doc, params) { - const { lineageAttribute, replaceWith, startingFromId, merge } = params; +function replaceLineage(doc, lineageAttributeName, params) { + const { replaceWith, startingFromId, merge } = params; // Replace the full lineage if (!startingFromId) { - return replaceWithinLineage(doc, lineageAttribute, replaceWith); + return replaceWithinLineage(doc, lineageAttributeName, replaceWith); } function getInitialState() { if (merge) { return { element: doc, - attributeName: lineageAttribute, + attributeName: lineageAttributeName, }; } return { - element: doc[lineageAttribute], + element: doc[lineageAttributeName], attributeName: 'parent', }; } @@ -52,6 +51,14 @@ function replaceLineage(doc, params) { return false; } +function replaceParentLineage(doc, params) { + return replaceLineage(doc, 'parent', params); +} + +function replaceContactLineage(doc, params) { + return replaceLineage(doc, 'contact', params); +} + const replaceWithinLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { if (!replaceWith) { const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; @@ -113,21 +120,18 @@ const createLineageFromDoc = doc => { /* Given a lineage, return the ids therein */ -const pluckIdsFromLineage = lineage => { - const result = []; - - let current = lineage; - while (current) { - result.push(current._id); - current = current.parent; +const pluckIdsFromLineage = (lineage, results = []) => { + if (!lineage) { + return results; } - return result; + return pluckIdsFromLineage(lineage.parent, [...results, lineage._id]); }; module.exports = { createLineageFromDoc, minifyLineagesInDoc, pluckIdsFromLineage, - replaceLineage, + replaceParentLineage, + replaceContactLineage, }; diff --git a/test/lib/hierarchy-operations/lineage-manipulation.spec.js b/test/lib/hierarchy-operations/lineage-manipulation.spec.js index 80077aa9f..54715901c 100644 --- a/test/lib/hierarchy-operations/lineage-manipulation.spec.js +++ b/test/lib/hierarchy-operations/lineage-manipulation.spec.js @@ -1,36 +1,21 @@ const { expect } = require('chai'); -const { replaceLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); +const { replaceParentLineage, replaceContactLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; const { parentsToLineage } = require('../../mock-hierarchies'); describe('lineage manipulation', () => { - describe('replaceLineage', () => { - const mockReport = data => Object.assign({ _id: 'r', type: 'data_record', contact: parentsToLineage('parent', 'grandparent') }, data); - const mockContact = data => Object.assign({ _id: 'c', type: 'person', parent: parentsToLineage('parent', 'grandparent') }, data); - - it('replace with empty lineage', () => { - const mock = mockReport(); - const replaceLineageOptions = { - lineageAttribute: 'contact', - replaceWith: undefined, - }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; - expect(mock).to.deep.eq({ - _id: 'r', - type: 'data_record', - contact: undefined, - }); - }); + const mockReport = data => Object.assign({ _id: 'r', type: 'data_record', contact: parentsToLineage('parent', 'grandparent') }, data); + const mockContact = data => Object.assign({ _id: 'c', type: 'person', parent: parentsToLineage('parent', 'grandparent') }, data); + describe('replaceParentLineage', () => { it('replace full lineage', () => { const mock = mockContact(); const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: parentsToLineage('new_parent'), }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -43,10 +28,9 @@ describe('lineage manipulation', () => { delete mock.parent; const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: parentsToLineage('new_parent'), }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -59,21 +43,19 @@ describe('lineage manipulation', () => { delete mock.parent; const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: undefined, }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.false; }); it('replace lineage starting at contact', () => { const mock = mockContact(); const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: parentsToLineage('new_grandparent'), startingFromId: 'parent', }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -84,12 +66,11 @@ describe('lineage manipulation', () => { it('merge new parent', () => { const mock = mockContact(); const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: parentsToLineage('new_parent', 'new_grandparent'), startingFromId: 'parent', merge: true, }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -97,30 +78,13 @@ describe('lineage manipulation', () => { }); }); - it('merge grandparent of contact', () => { - const mock = mockReport(); - const replaceLineageOptions = { - lineageAttribute: 'contact', - replaceWith: parentsToLineage('new_grandparent'), - startingFromId: 'grandparent', - merge: true, - }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; - expect(mock).to.deep.eq({ - _id: 'r', - type: 'data_record', - contact: parentsToLineage('parent', 'new_grandparent'), - }); - }); - it('replace empty starting at contact', () => { const mock = mockContact(); const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: undefined, startingFromId: 'parent', }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -131,11 +95,40 @@ describe('lineage manipulation', () => { it('replace starting at non-existant contact', () => { const mock = mockContact(); const replaceLineageOptions = { - lineageAttribute: 'parent', replaceWith: parentsToLineage('irrelevant'), startingFromId: 'dne', }; - expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; + expect(replaceParentLineage(mock, replaceLineageOptions)).to.be.false; + }); + }); + + describe('replaceContactLineage', () => { + it('replace with empty lineage', () => { + const mock = mockReport(); + const replaceLineageOptions = { + replaceWith: undefined, + }; + expect(replaceContactLineage(mock, replaceLineageOptions)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'r', + type: 'data_record', + contact: undefined, + }); + }); + + it('merge grandparent of contact', () => { + const mock = mockReport(); + const replaceLineageOptions = { + replaceWith: parentsToLineage('new_grandparent'), + startingFromId: 'grandparent', + merge: true, + }; + expect(replaceContactLineage(mock, replaceLineageOptions)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'r', + type: 'data_record', + contact: parentsToLineage('parent', 'new_grandparent'), + }); }); }); From 88ea9fd0e4951048e49037f5f0d4826766d15bc7 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 02:01:23 -0800 Subject: [PATCH 31/66] Still code reviewing --- package-lock.json | 1 - package.json | 1 - src/fn/merge-contacts.js | 23 ++++----- .../hierarchy-data-source.js | 4 +- src/lib/hierarchy-operations/index.js | 49 +++++++++---------- .../lineage-constraints.js | 5 ++ .../lineage-manipulation.js | 9 ++-- test/fn/merge-contacts.spec.js | 2 +- .../lineage-constraints.spec.js | 7 +++ 9 files changed, 54 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09d2cfac7..60ff114c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", - "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/package.json b/package.json index 1f5a35ba2..c06ae5d57 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", - "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index ef3f20211..6140d6726 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -24,22 +24,22 @@ module.exports = { const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const sourceIds = (args.remove || '') + const sourceIds = (args.sources || args.source || '') .split(',') .filter(Boolean); - if (!args.keep) { + if (!args.destination) { usage(); - throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--keep')}. Other contacts will be merged into this contact.`); + throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--destination')}. Other contacts will be merged into this contact.`); } if (sourceIds.length === 0) { usage(); - throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--remove')}. These contacts will be merged into the contact specified by ${bold('--keep')}`); + throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--sources')}. These contacts will be merged into the contact specified by ${bold('--destination')}`); } return { - destinationId: args.keep, + destinationId: args.destination, sourceIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, @@ -50,17 +50,18 @@ const bold = text => `\x1b[1m${text}\x1b[0m`; const usage = () => { info(` ${bold('cht-conf\'s merge-contacts action')} -When combined with 'upload-docs' this action merges multiple contacts and all their associated data into one. +When combined with 'upload-docs' this action moves all of the contacts and reports under ${bold('sources')} to be under ${bold('destination')}. +The top-level contact(s) ${bold('at source')} are deleted and no data in this document is merged or preserved. ${bold('USAGE')} -cht --local merge-contacts -- --keep= --remove=, +cht --local merge-contacts -- --destination= --sources=, ${bold('OPTIONS')} ---keep= - Specifies the ID of the contact that should have all other contact data merged into it. +--destination= + Specifies the ID of the contact that should receive the moving contacts and reports. ---remove=, - A comma delimited list of IDs of contacts which will be deleted and all of their data will be merged into the keep contact. +--sources=, + A comma delimited list of IDs of contacts which will be deleted. The hierarchy of contacts and reports under it will be moved to be under the destination contact. --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index 73baa9588..da5ec33a4 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -1,4 +1,3 @@ -const _ = require('lodash'); const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; @@ -73,7 +72,8 @@ async function getReportsForContacts(db, createdByIds, createdAtId, skip) { skip, }); - return _.uniqBy(reports.rows.map(row => row.doc), '_id'); + const docsWithId = reports.rows.map(({ doc }) => [doc._id, doc]); + return Array.from(new Map(docsWithId).values()); } async function getAncestorsOf(db, contactDoc) { diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 7af2aaa65..09e23edc5 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -70,11 +70,9 @@ async function moveReports(db, options, moveContext) { const createdAtId = options.merge && moveContext.sourceId; reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip); - const updatedReports = replaceLineageInReports(options, reportDocsBatch, moveContext); - - if (options.merge) { - reassignReports(reportDocsBatch, moveContext, updatedReports); - } + const lineageUpdates = replaceLineageInReports(options, reportDocsBatch, moveContext); + const reassignUpdates = reassignReports(options, reportDocsBatch, moveContext); + const updatedReports = reportDocsBatch.filter(doc => lineageUpdates.has(doc._id) || reassignUpdates.has(doc._id)); minifyLineageAndWriteToDisk(options, updatedReports); @@ -84,25 +82,22 @@ async function moveReports(db, options, moveContext) { return skip; } -function reassignReports(reports, { sourceId, destinationId }, updatedReports) { +function reassignReports(options, reports, { sourceId, destinationId }) { function reassignReportWithSubject(report, subjectId) { - let updated = false; if (report[subjectId] === sourceId) { report[subjectId] = destinationId; - updated = true; + updated.add(report._id); } if (report.fields[subjectId] === sourceId) { report.fields[subjectId] = destinationId; - updated = true; + updated.add(report._id); } + } - if (updated) { - const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); - if (!isAlreadyUpdated) { - updatedReports.push(report); - } - } + const updated = new Set(); + if (!options.merge) { + return updated; } for (const report of reports) { @@ -111,6 +106,8 @@ function reassignReports(reports, { sourceId, destinationId }, updatedReports) { reassignReportWithSubject(report, subjectId); } } + + return updated; } function minifyLineageAndWriteToDisk(options, docs) { @@ -120,19 +117,21 @@ function minifyLineageAndWriteToDisk(options, docs) { }); } -function replaceLineageInReports(options, reportsCreatedByDescendants, moveContext) { - return reportsCreatedByDescendants.reduce((agg, doc) => { - const replaceLineageOptions = { - replaceWith: moveContext.replacementLineage, - startingFromId: moveContext.sourceId, - merge: options.merge, +function replaceLineageInReports(options, reports, moveContext) { + const replaceLineageOptions = { + replaceWith: moveContext.replacementLineage, + startingFromId: moveContext.sourceId, + merge: options.merge, }; - + + const updates = new Set(); + reports.forEach(doc => { if (lineageManipulation.replaceContactLineage(doc, replaceLineageOptions)) { - agg.push(doc); + updates.add(doc._id); } - return agg; - }, []); + }); + + return updates; } function replaceLineageInAncestors(descendantsAndSelf, ancestors) { diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 43d9ae3b7..e134861ab 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -1,4 +1,5 @@ const log = require('../log'); +const { HIERARCHY_ROOT } = require('./hierarchy-data-source'); const { trace } = log; const lineageManipulation = require('./lineage-manipulation'); @@ -103,6 +104,10 @@ const getMergeViolations = (sourceDoc, destinationDoc) => { return commonViolations; } + if ([sourceDoc._id, destinationDoc._id].includes(HIERARCHY_ROOT)) { + return `cannot merge using id: "${HIERARCHY_ROOT}"`; + } + const sourceContactType = getContactType(sourceDoc); const destinationContactType = getContactType(destinationDoc); if (sourceContactType !== destinationContactType) { diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index d0dbe3e1f..0ad9260cd 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -13,7 +13,7 @@ function replaceLineage(doc, lineageAttributeName, params) { // Replace the full lineage if (!startingFromId) { - return replaceWithinLineage(doc, lineageAttributeName, replaceWith); + return replaceEntireLineage(doc, lineageAttributeName, replaceWith); } function getInitialState() { @@ -33,7 +33,7 @@ function replaceLineage(doc, lineageAttributeName, params) { function traverseOne() { const compare = merge ? state.element[state.attributeName] : state.element; if (compare?._id === startingFromId) { - return replaceWithinLineage(state.element, state.attributeName, replaceWith); + return replaceEntireLineage(state.element, state.attributeName, replaceWith); } state.element = state.element[state.attributeName]; @@ -59,14 +59,11 @@ function replaceContactLineage(doc, params) { return replaceLineage(doc, 'contact', params); } -const replaceWithinLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { +const replaceEntireLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { if (!replaceWith) { const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; replaceInDoc[lineageAttributeName] = undefined; return lineageWasDeleted; - } else if (replaceInDoc[lineageAttributeName]) { - replaceInDoc[lineageAttributeName]._id = replaceWith._id; - replaceInDoc[lineageAttributeName].parent = replaceWith.parent; } else { replaceInDoc[lineageAttributeName] = replaceWith; } diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index c4f519ad5..fbb8ec6fe 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -14,7 +14,7 @@ describe('merge-contacts', () => { it('remove only', () => expect(() => parseExtraArgs(__dirname, ['--remove=a'])).to.throw('required contact')); it('remove and keeps', () => { - const args = ['--remove=food,is,tasty', '--keep=bar', '--docDirectoryPath=/', '--force=hi']; + const args = ['--sources=food,is,tasty', '--destination=bar', '--docDirectoryPath=/', '--force=hi']; expect(parseExtraArgs(__dirname, args)).to.deep.eq({ sourceIds: ['food', 'is', 'tasty'], destinationId: 'bar', diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index 67b36b5f4..a40270e6a 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -63,6 +63,13 @@ describe('lineage constriants', () => { await expect(runScenario([], 'a', 'a', true)).to.eventually.rejectedWith('self'); }); + it('cannot merge with id: "root"', async () => { + const mockDb = { get: () => ({ settings: { contact_types: [] } }) }; + const { assertNoHierarchyErrors } = await lineageConstraints(mockDb, { merge: true }); + const actual = () => assertNoHierarchyErrors({ _id: 'root', type: 'dne' }, { _id: 'foo', type: 'clinic' }); + expect(actual).to.throw('root'); + }); + describe('default schema', () => { it('no defined rules enforces defaults schema', async () => await expect(runScenario(undefined, 'district_hospital', 'health_center')).to.eventually.rejectedWith('cannot have parent')); From 296088ae29767e36cca91099c03dc7fcb7cd1a93 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 02:01:48 -0800 Subject: [PATCH 32/66] Eslint --- src/lib/hierarchy-operations/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 09e23edc5..6d0704ba9 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -57,7 +57,7 @@ function moveHierarchy(db, options) { } info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); - } + }; } async function moveReports(db, options, moveContext) { From 42c67893df0bf80063d2f1d09535bbb14b6e6769 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 02:06:11 -0800 Subject: [PATCH 33/66] One more --- src/lib/hierarchy-operations/lineage-constraints.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index e134861ab..a2ff9fd62 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -76,10 +76,6 @@ const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) } } - if (!mapTypeToAllowedParents) { - return 'hierarchy constraints are undefined'; - } - const commonViolations = getCommonViolations(sourceDoc, destinationDoc); const contactTypeError = getContactTypeError(); const circularHierarchyError = findCircularHierarchyErrors(); From 8f2bbd63716be8b831002cf666426135dd0ce888 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 02:16:30 -0800 Subject: [PATCH 34/66] Why 5? wtf --- src/lib/hierarchy-operations/index.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 6d0704ba9..9834f1728 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -47,7 +47,7 @@ function moveHierarchy(db, options) { minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]); - const movedReportsCount = await moveReports(db, options, moveContext, destinationId); + const movedReportsCount = await moveReports(db, options, moveContext); trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); affectedContactCount += updatedDescendants.length + updatedAncestors.length; @@ -166,16 +166,18 @@ function replaceLineageInContacts(options, moveContext) { } } - const result = []; - for (const doc of moveContext.descendantsAndSelf) { + function sonarQubeComplexityFiveIsTooLow(doc) { const docIsSource = doc._id === sourceId; // skip top-level because it will be deleted - if (options.merge && docIsSource) { - continue; + if (!options.merge || !docIsSource) { + return replaceForSingleContact(doc); } + } - const updatedDoc = replaceForSingleContact(doc); + const result = []; + for (const doc of moveContext.descendantsAndSelf) { + const updatedDoc = sonarQubeComplexityFiveIsTooLow(doc); if (updatedDoc) { result.push(updatedDoc); } From 4ecf723df05df7a3f98ed62cb9cf5ac8a9345c0e Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 11:55:37 -0800 Subject: [PATCH 35/66] Phrasing --- src/lib/hierarchy-operations/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 9834f1728..1b72dbdcc 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -169,7 +169,7 @@ function replaceLineageInContacts(options, moveContext) { function sonarQubeComplexityFiveIsTooLow(doc) { const docIsSource = doc._id === sourceId; - // skip top-level because it will be deleted + // skip source because it will be deleted if (!options.merge || !docIsSource) { return replaceForSingleContact(doc); } From 0a9db49ce3edf6ab61f86a03e578b2284f8976ab Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 11:56:43 -0800 Subject: [PATCH 36/66] Unneeded comment --- src/fn/upload-docs.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index ecb24787a..1595e0199 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -120,8 +120,6 @@ const preuploadAnalysis = filePaths => .filter(Boolean); const handleUsersAtDeletedFacilities = async deletedDocIds => { - // how can we know which ids are worth querying? what about when we have delete-contacts and delete 10000 places? - const affectedUsers = await getAffectedUsers(); const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); warn(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`); From 956c092dfbba1a8696e4d6a2579c10cc7e9f8b32 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 18:27:37 -0800 Subject: [PATCH 37/66] New action delete-contacts --- src/fn/delete-contacts.js | 59 + .../hierarchy-operations/delete-hierarchy.js | 42 + src/lib/hierarchy-operations/index.js | 16 +- src/lib/hierarchy-operations/jsdocFolder.js | 9 + test/fn/upload-docs.spec.js | 2 +- .../hierarchy-operations.spec.js | 1008 +++++++++-------- 6 files changed, 652 insertions(+), 484 deletions(-) create mode 100644 src/fn/delete-contacts.js create mode 100644 src/lib/hierarchy-operations/delete-hierarchy.js diff --git a/src/fn/delete-contacts.js b/src/fn/delete-contacts.js new file mode 100644 index 000000000..09a682efd --- /dev/null +++ b/src/fn/delete-contacts.js @@ -0,0 +1,59 @@ +const minimist = require('minimist'); +const path = require('path'); + +const environment = require('../lib/environment'); +const pouch = require('../lib/db'); +const { info } = require('../lib/log'); + +const HierarchyOperations = require('../lib/hierarchy-operations'); + +module.exports = { + requiresInstance: true, + execute: () => { + const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); + const db = pouch(); + const options = { + docDirectoryPath: args.docDirectoryPath, + force: args.force, + }; + return HierarchyOperations(db, options).delete(args.sourceIds); + } +}; + +// Parses extraArgs and asserts if required parameters are not present +const parseExtraArgs = (projectDir, extraArgs = []) => { + const args = minimist(extraArgs, { boolean: true }); + + const sourceIds = (args.ids || args.id || '') + .split(',') + .filter(id => id); + + if (sourceIds.length === 0) { + usage(); + throw Error('Action "delete-contacts" is missing required list of contacts to be deleted'); + } + + return { + sourceIds, + docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), + force: !!args.force, + }; +}; + +const bold = text => `\x1b[1m${text}\x1b[0m`; +const usage = () => { + info(` +${bold('cht-conf\'s delete-contacts action')} +When combined with 'upload-docs' this action recursively deletes a contact and all of their descendant contacts and data. ${bold('This operation is permanent. It cannot be undone.')} + +${bold('USAGE')} +cht --local delete-contacts -- --ids=, + +${bold('OPTIONS')} +--ids=, + A comma delimited list of ids of contacts to be deleted. + +--docDirectoryPath= + Specifies the folder used to store the documents representing the changes in hierarchy. +`); +}; diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js new file mode 100644 index 000000000..a8e2dc555 --- /dev/null +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -0,0 +1,42 @@ +const DataSource = require('./hierarchy-data-source'); +const JsDocs = require('./jsdocFolder'); +const { trace, info } = require('../log'); + +const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; +async function deleteHierarchy(db, options, sourceIds) { + console.log(db, options, sourceIds); + const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); + for (const sourceId of sourceIds) { + const sourceDoc = sourceDocs[sourceId]; + trace(`Deleting descendants and reports under: ${prettyPrintDocument(sourceDoc)}`); + const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId); + + let affectedReportCount = 0; + for (const descendant of descendantsAndSelf) { + JsDocs.deleteDoc(options, descendant); + affectedReportCount += await deleteReportsForContact(db, options, descendant); + } + + const affectedContactCount = descendantsAndSelf.length; + + info(`Staged updates to delete ${prettyPrintDocument(sourceDoc)}. ${affectedContactCount.length} contact(s) and ${affectedReportCount} report(s).`); + } +} + +async function deleteReportsForContact(db, options, contact) { + let skip = 0; + let reportBatch; + do { + reportBatch = await DataSource.getReportsForContacts(db, [], contact._id, skip); + + for (const report of reportBatch) { + JsDocs.deleteDoc(options, report); + } + + skip += reportBatch.length; + } while (reportBatch.length >= DataSource.BATCH_SIZE); + + return skip + reportBatch.length; +} + +module.exports = deleteHierarchy; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 1b72dbdcc..8f3db98ef 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -1,10 +1,10 @@ +const DataSource = require('./hierarchy-data-source'); +const deleteHierarchy = require('./delete-hierarchy'); +const JsDocs = require('./jsdocFolder'); const lineageManipulation = require('./lineage-manipulation'); const LineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); -const JsDocs = require('./jsdocFolder'); -const DataSource = require('./hierarchy-data-source'); - function moveHierarchy(db, options) { return async function (sourceIds, destinationId) { JsDocs.prepareFolder(options); @@ -28,11 +28,7 @@ function moveHierarchy(db, options) { }; if (options.merge) { - JsDocs.writeDoc(options, { - _id: sourceDoc._id, - _rev: sourceDoc._rev, - _deleted: true, - }); + JsDocs.deleteDoc(options, sourceDoc); } const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; @@ -186,11 +182,11 @@ function replaceLineageInContacts(options, moveContext) { return result; } -module.exports = (db, options) => { +module.exports = (db, options = {}) => { return { HIERARCHY_ROOT: DataSource.HIERARCHY_ROOT, move: moveHierarchy(db, { ...options, merge: false }), merge: moveHierarchy(db, { ...options, merge: true }), + delete: async (sourceIds) => deleteHierarchy(db, options, sourceIds), }; }; - diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js index b24358acb..2e220740d 100644 --- a/src/lib/hierarchy-operations/jsdocFolder.js +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -30,7 +30,16 @@ function deleteAfterConfirmation(docDirectoryPath) { fs.deleteFilesInFolder(docDirectoryPath); } +function deleteDoc(options, doc) { + writeDoc(options, { + _id: doc._id, + _rev: doc._rev, + _deleted: true, + }); +} + module.exports = { + deleteDoc, prepareFolder, writeDoc, }; diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 44dc5cf77..0eca01086 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -136,7 +136,7 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); - describe('kenn --disable-users', () => { + describe('--disable-users', () => { beforeEach(async () => { sinon.stub(environment, 'extraArgs').get(() => ['--disable-users']); await assertDbEmpty(); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index c18cd0250..2fa29421d 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -4,8 +4,8 @@ const rewire = require('rewire'); const sinon = require('sinon'); const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); -const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder.js'); -const DataSource = rewire('../../../src/lib/hierarchy-operations/hierarchy-data-source.js'); +const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder'); +const DataSource = rewire('../../../src/lib/hierarchy-operations/hierarchy-data-source'); const PouchDB = require('pouchdb-core'); @@ -15,9 +15,14 @@ PouchDB.plugin(require('pouchdb-mapreduce')); const { assert, expect } = chai; -const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); +const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations'); +const deleteHierarchy = rewire('../../../src/lib/hierarchy-operations/delete-hierarchy'); + HierarchyOperations.__set__('JsDocs', JsDocs); HierarchyOperations.__set__('DataSource', DataSource); +deleteHierarchy.__set__('JsDocs', JsDocs); +deleteHierarchy.__set__('DataSource', DataSource); +HierarchyOperations.__set__('deleteHierarchy', deleteHierarchy); const contacts_by_depth = { // eslint-disable-next-line quotes @@ -29,7 +34,7 @@ const reports_by_freetext = { map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" }; -describe('move-contacts', () => { +describe('hierarchy-operations', () => { let pouchDb, scenarioCount = 0; const writtenDocs = []; const getWrittenDoc = docId => { @@ -55,7 +60,7 @@ describe('move-contacts', () => { const updateHierarchyRules = contact_types => upsert('settings', { settings: { contact_types } }); beforeEach(async () => { - pouchDb = new PouchDB(`move-contacts-${scenarioCount++}`); + pouchDb = new PouchDB(`hierarchy-operations-${scenarioCount++}`); await mockHierarchy(pouchDb, { district_1: { @@ -92,296 +97,553 @@ describe('move-contacts', () => { }); JsDocs.writeDoc = (docDirectoryPath, doc) => writtenDocs.push(doc); + JsDocs.__set__('writeDoc', JsDocs.writeDoc); + JsDocs.prepareFolder = () => {}; writtenDocs.length = 0; }); afterEach(async () => pouchDb.destroy()); - it('move health_center_1 to district_2', async () => { - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + describe('move', () => { + it('move health_center_1 to district_2', async () => { + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_2'), - }); + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_2'), + }); - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_2'), - }); + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_2'), + }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), - }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), + }); - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); }); - }); - it('move health_center_1 to root', async () => { - sinon.spy(pouchDb, 'query'); + it('move health_center_1 to root', async () => { + sinon.spy(pouchDb, 'query'); - await updateHierarchyRules([{ id: 'health_center', parents: [] }]); + await updateHierarchyRules([{ id: 'health_center', parents: [] }]); - await HierarchyOperations(pouchDb).move(['health_center_1'], 'root'); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'root'); - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1'), - }); + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1'), + }); - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1'), - parent: parentsToLineage(), - }); + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1'), + parent: parentsToLineage(), + }); - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1'), - parent: parentsToLineage('health_center_1'), - }); + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1'), + parent: parentsToLineage('health_center_1'), + }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1'), - }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1'), + }); - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1'), - }); + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1'), + }); - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.callCount).to.equal(2); - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 10000, skip: 0, group_level: undefined }], - ]); - }); + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.callCount).to.equal(2); + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 10000, skip: 0, group_level: undefined }], + ]); + }); - it('move district_1 from root', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); + it('move district_1 from root', async () => { + await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); + await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); - expect(getWrittenDoc('district_1')).to.deep.eq({ - _id: 'district_1', - type: 'district_hospital', - contact: parentsToLineage('district_1_contact', 'district_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); + expect(getWrittenDoc('district_1')).to.deep.eq({ + _id: 'district_1', + type: 'district_hospital', + contact: parentsToLineage('district_1_contact', 'district_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), + }); - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('district_1', 'district_2'), - }); + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), + parent: parentsToLineage('district_1', 'district_2'), + }); - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), + }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'district_2'), - }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'district_2'), + }); - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), + }); }); - }); - it('move district_1 to flexible hierarchy parent', async () => { - await pouchDb.put({ - _id: `county_1`, - type: 'contact', - contact_type: 'county', - }); + it('move district_1 to flexible hierarchy parent', async () => { + await pouchDb.put({ + _id: `county_1`, + type: 'contact', + contact_type: 'county', + }); - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'district_hospital', parents: ['county'] }, - ]); + await updateHierarchyRules([ + { id: 'county', parents: [] }, + { id: 'district_hospital', parents: ['county'] }, + ]); - await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); + await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), + }); - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('district_1', 'county_1'), - }); + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), + parent: parentsToLineage('district_1', 'county_1'), + }); - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'county_1'), + parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), + }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'county_1'), - }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'county_1'), + }); - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), + }); }); - }); - it('moves flexible hierarchy contact to flexible hierarchy parent', async () => { - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'subcounty', parents: ['county'] }, - { id: 'focal', parents: ['county', 'subcounty'], person: true } - ]); + it('moves flexible hierarchy contact to flexible hierarchy parent', async () => { + await updateHierarchyRules([ + { id: 'county', parents: [] }, + { id: 'subcounty', parents: ['county'] }, + { id: 'focal', parents: ['county', 'subcounty'], person: true } + ]); - await pouchDb.bulkDocs([ - { _id: `county`, type: 'contact', contact_type: 'county' }, - { _id: `subcounty`, type: 'contact', contact_type: 'subcounty', parent: { _id: 'county' } }, - { _id: `focal`, type: 'contact', contact_type: 'focal', parent: { _id: 'county' } }, - ]); + await pouchDb.bulkDocs([ + { _id: `county`, type: 'contact', contact_type: 'county' }, + { _id: `subcounty`, type: 'contact', contact_type: 'subcounty', parent: { _id: 'county' } }, + { _id: `focal`, type: 'contact', contact_type: 'focal', parent: { _id: 'county' } }, + ]); - await mockReport(pouchDb, { - id: 'report_focal', - creatorId: 'focal', - }); + await mockReport(pouchDb, { + id: 'report_focal', + creatorId: 'focal', + }); - await HierarchyOperations(pouchDb).move(['focal'], 'subcounty'); + await HierarchyOperations(pouchDb).move(['focal'], 'subcounty'); - expect(getWrittenDoc('focal')).to.deep.eq({ - _id: 'focal', - type: 'contact', - contact_type: 'focal', - parent: parentsToLineage('subcounty', 'county'), - }); + expect(getWrittenDoc('focal')).to.deep.eq({ + _id: 'focal', + type: 'contact', + contact_type: 'focal', + parent: parentsToLineage('subcounty', 'county'), + }); - expect(getWrittenDoc('report_focal')).to.deep.eq({ - _id: 'report_focal', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('focal', 'subcounty', 'county'), + expect(getWrittenDoc('report_focal')).to.deep.eq({ + _id: 'report_focal', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('focal', 'subcounty', 'county'), + }); }); - }); - it('moving primary contact updates parents', async () => { - await mockHierarchy(pouchDb, { - t_district_1: { - t_health_center_1: { - t_clinic_1: { - t_patient_1: {}, + it('moving primary contact updates parents', async () => { + await mockHierarchy(pouchDb, { + t_district_1: { + t_health_center_1: { + t_clinic_1: { + t_patient_1: {}, + }, + t_clinic_2: { + t_patient_2: {}, + } }, - t_clinic_2: { - t_patient_2: {}, - } }, - }, - }); + }); - const patient1Lineage = parentsToLineage('t_patient_1', 't_clinic_1', 't_health_center_1', 't_district_1'); - await upsert('t_health_center_1', { - type: 'health_center', - contact: patient1Lineage, - parent: parentsToLineage('t_district_1'), - }); + const patient1Lineage = parentsToLineage('t_patient_1', 't_clinic_1', 't_health_center_1', 't_district_1'); + await upsert('t_health_center_1', { + type: 'health_center', + contact: patient1Lineage, + parent: parentsToLineage('t_district_1'), + }); - await upsert('t_district_1', { - type: 'district_hospital', - contact: patient1Lineage, - parent: parentsToLineage(), - }); + await upsert('t_district_1', { + type: 'district_hospital', + contact: patient1Lineage, + parent: parentsToLineage(), + }); - await HierarchyOperations(pouchDb).move(['t_patient_1'], 't_clinic_2'); + await HierarchyOperations(pouchDb).move(['t_patient_1'], 't_clinic_2'); - expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ - _id: 't_health_center_1', - type: 'health_center', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), - parent: parentsToLineage('t_district_1'), - }); + expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ + _id: 't_health_center_1', + type: 'health_center', + contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), + parent: parentsToLineage('t_district_1'), + }); + + expect(getWrittenDoc('t_district_1')).to.deep.eq({ + _id: 't_district_1', + type: 'district_hospital', + contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), + }); - expect(getWrittenDoc('t_district_1')).to.deep.eq({ - _id: 't_district_1', - type: 'district_hospital', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), + expectWrittenDocs(['t_patient_1', 't_district_1', 't_health_center_1']); }); - expectWrittenDocs(['t_patient_1', 't_district_1', 't_health_center_1']); - }); + // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' + it('district_hospital with empty string parent is not preserved', async () => { + await upsert('district_2', { parent: '', type: 'district_hospital' }); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); - // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' - it('district_hospital with empty string parent is not preserved', async () => { - await upsert('district_2', { parent: '', type: 'district_hospital' }); - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + }); - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), + it('documents should be minified', async () => { + await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); + const patient = { + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), + type: 'person', + important: true, + }; + const clinic = { + parent: parentsToLineage('health_center_1', 'district_1'), + type: 'clinic', + important: true, + }; + patient.parent.important = false; + clinic.parent.parent.important = false; + + await upsert('clinic_1', clinic); + await upsert('patient_1', patient); + + await HierarchyOperations(pouchDb).move(['clinic_1'], 'district_2'); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + important: true, + parent: parentsToLineage('district_2'), + }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + important: true, + parent: parentsToLineage('clinic_1', 'district_2'), + }); + }); + + it('cannot create circular hierarchy', async () => { + // even if the hierarchy rules allow it + await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); + + try { + await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); + assert.fail('should throw'); + } catch (err) { + expect(err.message).to.include('circular'); + } + }); + + it('throw if parent does not exist', async () => { + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'dne_parent_id'); + await expect(actual).to.eventually.rejectedWith('could not be found'); + }); + + it('throw when altering same lineage', async () => { + const actual = HierarchyOperations(pouchDb).move(['patient_1', 'health_center_1'], 'district_2'); + await expect(actual).to.eventually.rejectedWith('same lineage'); + }); + + it('throw if contact_id is not a contact', async () => { + const actual = HierarchyOperations(pouchDb).move(['report_1'], 'clinic_1'); + await expect(actual).to.eventually.rejectedWith('unknown type'); + }); + + it('throw if moving primary contact of parent', async () => { + const actual = HierarchyOperations(pouchDb).move(['clinic_1_contact'], 'district_1'); + await expect(actual).to.eventually.rejectedWith('primary contact'); + }); + + it('throw if setting parent to self', async () => { + await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); + await expect(actual).to.eventually.rejectedWith('circular'); + }); + + it('throw when moving place to unconfigured parent', async () => { + await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); + const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); + await expect(actual).to.eventually.rejectedWith('parent of type'); + }); + + describe('batching works as expected', () => { + const initialBatchSize = DataSource.BATCH_SIZE; + beforeEach(async () => { + await mockReport(pouchDb, { + id: 'report_2', + creatorId: 'health_center_1_contact', + }); + + await mockReport(pouchDb, { + id: 'report_3', + creatorId: 'health_center_1_contact', + }); + + await mockReport(pouchDb, { + id: 'report_4', + creatorId: 'health_center_1_contact', + }); + }); + + afterEach(() => { + DataSource.BATCH_SIZE = initialBatchSize; + DataSource.__set__('BATCH_SIZE', initialBatchSize); + }); + + it('move health_center_1 to district_2 in batches of 1', async () => { + DataSource.__set__('BATCH_SIZE', 1); + DataSource.BATCH_SIZE = 1; + sinon.spy(pouchDb, 'query'); + + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_2')).to.deep.eq({ + _id: 'report_2', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_3')).to.deep.eq({ + _id: 'report_3', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(pouchDb.query.callCount).to.deep.equal(6); + + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 0, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 1, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 2, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 3, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 4, group_level: undefined }], + ]); + }); + + it('should health_center_1 to district_1 in batches of 2', async () => { + DataSource.__set__('BATCH_SIZE', 2); + DataSource.BATCH_SIZE = 2; + sinon.spy(pouchDb, 'query'); + + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + parent: parentsToLineage('district_1'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1'), + parent: parentsToLineage('health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_2')).to.deep.eq({ + _id: 'report_2', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_3')).to.deep.eq({ + _id: 'report_3', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(pouchDb.query.callCount).to.deep.equal(4); + + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 0, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 2, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 4, group_level: undefined }] + ]); + }); }); }); - describe('merging', () => { + describe('merge', () => { it('merge district_2 into district_1', async () => { // setup await mockReport(pouchDb, { @@ -509,256 +771,56 @@ describe('move-contacts', () => { }); }); - it('documents should be minified', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); - const patient = { - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - type: 'person', - important: true, - }; - const clinic = { - parent: parentsToLineage('health_center_1', 'district_1'), - type: 'clinic', - important: true, - }; - patient.parent.important = false; - clinic.parent.parent.important = false; - - await upsert('clinic_1', clinic); - await upsert('patient_1', patient); - - await HierarchyOperations(pouchDb).move(['clinic_1'], 'district_2'); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - important: true, - parent: parentsToLineage('district_2'), - }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - important: true, - parent: parentsToLineage('clinic_1', 'district_2'), - }); - }); - - it('cannot create circular hierarchy', async () => { - // even if the hierarchy rules allow it - await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); - - try { - await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('circular'); - } - }); - - it('throw if parent does not exist', async () => { - const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'dne_parent_id'); - await expect(actual).to.eventually.rejectedWith('could not be found'); - }); - - it('throw when altering same lineage', async () => { - const actual = HierarchyOperations(pouchDb).move(['patient_1', 'health_center_1'], 'district_2'); - await expect(actual).to.eventually.rejectedWith('same lineage'); - }); - - it('throw if contact_id is not a contact', async () => { - const actual = HierarchyOperations(pouchDb).move(['report_1'], 'clinic_1'); - await expect(actual).to.eventually.rejectedWith('unknown type'); - }); - - it('throw if moving primary contact of parent', async () => { - const actual = HierarchyOperations(pouchDb).move(['clinic_1_contact'], 'district_1'); - await expect(actual).to.eventually.rejectedWith('primary contact'); - }); - - it('throw if setting parent to self', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); - const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); - await expect(actual).to.eventually.rejectedWith('circular'); - }); - - it('throw when moving place to unconfigured parent', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); - const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); - await expect(actual).to.eventually.rejectedWith('parent of type'); - }); - - describe('batching works as expected', () => { - const initialBatchSize = DataSource.BATCH_SIZE; - beforeEach(async () => { - await mockReport(pouchDb, { - id: 'report_2', - creatorId: 'health_center_1_contact', + describe('delete', () => { + const expectDelted = id => { + expect(getWrittenDoc(id)).to.deep.eq({ + _id: id, + _deleted: true, }); + }; + it('delete district_2', async () => { + // setup await mockReport(pouchDb, { - id: 'report_3', - creatorId: 'health_center_1_contact', + id: 'district_report', + creatorId: 'health_center_2_contact', + patientId: 'district_2' }); - + await mockReport(pouchDb, { - id: 'report_4', - creatorId: 'health_center_1_contact', - }); - }); - - afterEach(() => { - DataSource.BATCH_SIZE = initialBatchSize; - DataSource.__set__('BATCH_SIZE', initialBatchSize); - }); - - it('move health_center_1 to district_2 in batches of 1', async () => { - DataSource.__set__('BATCH_SIZE', 1); - DataSource.BATCH_SIZE = 1; - sinon.spy(pouchDb, 'query'); - - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + id: 'patient_report', + creatorId: 'health_center_2_contact', + patientId: 'patient_2' }); - - expect(pouchDb.query.callCount).to.deep.equal(6); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] + + // action + await HierarchyOperations(pouchDb).delete(['district_2']); + + // assert + const deletedDocIds = [ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'district_report', 'patient_report', ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 1, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 3, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 4, group_level: undefined }], - ]); + expectWrittenDocs(deletedDocIds); + deletedDocIds.forEach(id => expectDelted(id)); }); - it('should health_center_1 to district_1 in batches of 2', async () => { - DataSource.__set__('BATCH_SIZE', 2); - DataSource.BATCH_SIZE = 2; - sinon.spy(pouchDb, 'query'); - - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - parent: parentsToLineage('district_1'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1'), - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + it('reports created by deleted contacts are not deleted', async () => { + // setup + await mockReport(pouchDb, { + id: 'other_report', + creatorId: 'health_center_2_contact', + patientId: 'other' }); + + // action + await HierarchyOperations(pouchDb).delete(['district_2']); - expect(pouchDb.query.callCount).to.deep.equal(4); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 4, group_level: undefined }] - ]); + const writtenIds = writtenDocs.map(doc => doc._id); + expect(writtenIds).to.not.have.members(['other_report']); }); }); }); From c4aff9779cc4c794690cc51a270bb46550b53bba Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 18:34:41 -0800 Subject: [PATCH 38/66] Eslint --- src/lib/hierarchy-operations/delete-hierarchy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index a8e2dc555..b44d76eff 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -4,7 +4,6 @@ const { trace, info } = require('../log'); const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; async function deleteHierarchy(db, options, sourceIds) { - console.log(db, options, sourceIds); const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); for (const sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; From adabd15e3afa2018e31e24660ca0d28ec4397dc1 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 18:57:32 -0800 Subject: [PATCH 39/66] SonarQubing --- test/lib/hierarchy-operations/hierarchy-operations.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 2fa29421d..db4db8ad6 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -820,7 +820,7 @@ describe('hierarchy-operations', () => { await HierarchyOperations(pouchDb).delete(['district_2']); const writtenIds = writtenDocs.map(doc => doc._id); - expect(writtenIds).to.not.have.members(['other_report']); + expect(writtenIds).to.not.include(['other_report']); }); }); }); From 6ce9c1ab4fa6f5a91b0254fac03275152659da2c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 6 Dec 2024 18:59:10 -0800 Subject: [PATCH 40/66] Oops --- src/lib/hierarchy-operations/delete-hierarchy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index b44d76eff..d6bfcbe76 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -35,7 +35,7 @@ async function deleteReportsForContact(db, options, contact) { skip += reportBatch.length; } while (reportBatch.length >= DataSource.BATCH_SIZE); - return skip + reportBatch.length; + return skip; } module.exports = deleteHierarchy; From af9a9aca3dc0790d5acdd4f92381591670b54e8e Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 19:52:40 -0800 Subject: [PATCH 41/66] lineage-manipulation refactor --- .../lineage-manipulation.js | 76 +----------------- .../hierarchy-operations/replace-lineage.js | 77 +++++++++++++++++++ .../hierarchy-operations.spec.js | 1 - 3 files changed, 81 insertions(+), 73 deletions(-) create mode 100644 src/lib/hierarchy-operations/replace-lineage.js diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 0ad9260cd..4ce274e8d 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -1,75 +1,4 @@ - -/** - * Given a doc, replace the lineage information therein with "replaceWith" - * - * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing - * @param {Object} params SonarQube - * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } - * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced - * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced - */ -function replaceLineage(doc, lineageAttributeName, params) { - const { replaceWith, startingFromId, merge } = params; - - // Replace the full lineage - if (!startingFromId) { - return replaceEntireLineage(doc, lineageAttributeName, replaceWith); - } - - function getInitialState() { - if (merge) { - return { - element: doc, - attributeName: lineageAttributeName, - }; - } - - return { - element: doc[lineageAttributeName], - attributeName: 'parent', - }; - } - - function traverseOne() { - const compare = merge ? state.element[state.attributeName] : state.element; - if (compare?._id === startingFromId) { - return replaceEntireLineage(state.element, state.attributeName, replaceWith); - } - - state.element = state.element[state.attributeName]; - state.attributeName = 'parent'; - } - - const state = getInitialState(); - while (state.element) { - const result = traverseOne(); - if (result) { - return result; - } - } - - return false; -} - -function replaceParentLineage(doc, params) { - return replaceLineage(doc, 'parent', params); -} - -function replaceContactLineage(doc, params) { - return replaceLineage(doc, 'contact', params); -} - -const replaceEntireLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { - if (!replaceWith) { - const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; - replaceInDoc[lineageAttributeName] = undefined; - return lineageWasDeleted; - } else { - replaceInDoc[lineageAttributeName] = replaceWith; - } - - return true; -}; +const { replaceContactLineage, replaceParentLineage } = require('./replace-lineage'); /* Function borrowed from shared-lib/lineage @@ -96,6 +25,9 @@ const minifyLineagesInDoc = doc => { if ('contact' in doc) { doc.contact = minifyLineage(doc.contact); + if (doc.contact && !doc.contact.parent) { + delete doc.contact.parent; // for unit test clarity + } } if (doc.type === 'data_record') { diff --git a/src/lib/hierarchy-operations/replace-lineage.js b/src/lib/hierarchy-operations/replace-lineage.js new file mode 100644 index 000000000..7e7cdbec5 --- /dev/null +++ b/src/lib/hierarchy-operations/replace-lineage.js @@ -0,0 +1,77 @@ +function replaceLineage(doc, lineageAttributeName, params) { + // Replace the full lineage + if (!params.startingFromId) { + return replaceEntireLineage(doc, lineageAttributeName, params.replaceWith); + } + + const selectedFunction = params.merge ? replaceLineageForMerge : replaceLineageForMove; + return selectedFunction(doc, lineageAttributeName, params); +} + +function replaceLineageForMove(doc, lineageAttributeName, params) { + let currentElement = doc[lineageAttributeName]; + while (currentElement) { + if (currentElement?._id === params.startingFromId) { + return replaceEntireLineage(currentElement, 'parent', params.replaceWith); + } + + currentElement = currentElement.parent; + } + + return false; +} + +function replaceLineageForMerge(doc, lineageAttributeName, params) { + let currentElement = doc; + let currentAttributeName = lineageAttributeName; + while (currentElement) { + if (currentElement[currentAttributeName]?._id === params.startingFromId) { + return replaceEntireLineage(currentElement, currentAttributeName, params.replaceWith); + } + + currentElement = currentElement[currentAttributeName]; + currentAttributeName = 'parent'; + } + + return false; +} + +function replaceEntireLineage(replaceInDoc, lineageAttributeName, replaceWith) { + if (!replaceWith) { + const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; + replaceInDoc[lineageAttributeName] = undefined; + return lineageWasDeleted; + } else { + replaceInDoc[lineageAttributeName] = replaceWith; + } + + return true; +} + +module.exports = { +/** + * Given a doc, replace the lineage information therein with "replaceWith" + * + * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {Object} params + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced + */ + replaceParentLineage: (doc, params) => { + return replaceLineage(doc, 'parent', params); + }, + +/** + * Given a doc, replace the lineage information therein with "replaceWith" + * + * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {Object} params + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced + */ + replaceContactLineage: (doc, params) => { + return replaceLineage(doc, 'contact', params); + }, +}; diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index c18cd0250..7c47df713 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -464,7 +464,6 @@ describe('move-contacts', () => { type: 'data_record', contact: { _id: 'dne', - parent: undefined, }, fields: { patient_uuid: 'district_1' From 546f9cb29f202d7ed3a4099a9e46b77f5d3747bc Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 20:29:29 -0800 Subject: [PATCH 42/66] Docs --- src/lib/hierarchy-operations/replace-lineage.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/hierarchy-operations/replace-lineage.js b/src/lib/hierarchy-operations/replace-lineage.js index 7e7cdbec5..018730b14 100644 --- a/src/lib/hierarchy-operations/replace-lineage.js +++ b/src/lib/hierarchy-operations/replace-lineage.js @@ -50,9 +50,9 @@ function replaceEntireLineage(replaceInDoc, lineageAttributeName, replaceWith) { module.exports = { /** - * Given a doc, replace the lineage information therein with "replaceWith" + * Given a doc, replace the parent's lineage * - * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {Object} doc A CouchDB document containing a parent lineage (eg. parent.parent._id) * @param {Object} params * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced @@ -63,9 +63,9 @@ module.exports = { }, /** - * Given a doc, replace the lineage information therein with "replaceWith" + * Given a doc, replace the contact's lineage * - * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {Object} doc A CouchDB document containing a contact lineage (eg. contact.parent._id) * @param {Object} params * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced From fe27a5a5c674f3f8f7f0eaeb6b58e1d51142d9b7 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 20:31:53 -0800 Subject: [PATCH 43/66] Oh that is why --- src/lib/hierarchy-operations/lineage-manipulation.js | 3 --- test/lib/hierarchy-operations/hierarchy-operations.spec.js | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js index 4ce274e8d..6cb2e3523 100644 --- a/src/lib/hierarchy-operations/lineage-manipulation.js +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -25,9 +25,6 @@ const minifyLineagesInDoc = doc => { if ('contact' in doc) { doc.contact = minifyLineage(doc.contact); - if (doc.contact && !doc.contact.parent) { - delete doc.contact.parent; // for unit test clarity - } } if (doc.type === 'data_record') { diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 7c47df713..c18cd0250 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -464,6 +464,7 @@ describe('move-contacts', () => { type: 'data_record', contact: { _id: 'dne', + parent: undefined, }, fields: { patient_uuid: 'district_1' From 28be7fba7335ccd5cf48c2677070a0518faa007a Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 20:49:54 -0800 Subject: [PATCH 44/66] Remove function nesting --- .../hierarchy-data-source.js | 2 +- src/lib/hierarchy-operations/index.js | 177 +++++++++--------- .../lineage-constraints.js | 48 ++--- 3 files changed, 116 insertions(+), 111 deletions(-) diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index da5ec33a4..fe912cf5e 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -92,8 +92,8 @@ async function getAncestorsOf(db, contactDoc) { } module.exports = { - HIERARCHY_ROOT, BATCH_SIZE, + HIERARCHY_ROOT, getAncestorsOf, getContactWithDescendants, getContact, diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 1b72dbdcc..47d4d0177 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -5,62 +5,61 @@ const { trace, info } = require('../log'); const JsDocs = require('./jsdocFolder'); const DataSource = require('./hierarchy-data-source'); -function moveHierarchy(db, options) { - return async function (sourceIds, destinationId) { - JsDocs.prepareFolder(options); - trace(`Fetching contact details: ${destinationId}`); - const constraints = await LineageConstraints(db, options); - const destinationDoc = await DataSource.getContact(db, destinationId); - const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); - constraints.assertNoHierarchyErrors(Object.values(sourceDocs), destinationDoc); - - let affectedContactCount = 0; - let affectedReportCount = 0; - const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); - for (const sourceId of sourceIds) { - const sourceDoc = sourceDocs[sourceId]; - const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId); - const moveContext = { - sourceId, - destinationId, - descendantsAndSelf, - replacementLineage, - }; - - if (options.merge) { - JsDocs.writeDoc(options, { - _id: sourceDoc._id, - _rev: sourceDoc._rev, - _deleted: true, - }); - } - - const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; - await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); - - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedDescendants = replaceLineageInContacts(options, moveContext); - - const ancestors = await DataSource.getAncestorsOf(db, sourceDoc); - trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); - - minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]); - - const movedReportsCount = await moveReports(db, options, moveContext); - trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); - - affectedContactCount += updatedDescendants.length + updatedAncestors.length; - affectedReportCount += movedReportsCount; - - info(`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); +async function moveHierarchy(db, options, sourceIds, destinationId) { + JsDocs.prepareFolder(options); + trace(`Fetching contact details: ${destinationId}`); + const constraints = await LineageConstraints(db, options); + const destinationDoc = await DataSource.getContact(db, destinationId); + const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); + constraints.assertNoHierarchyErrors(Object.values(sourceDocs), destinationDoc); + + let affectedContactCount = 0; + let affectedReportCount = 0; + const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); + for (const sourceId of sourceIds) { + const sourceDoc = sourceDocs[sourceId]; + const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId); + const moveContext = { + sourceId, + destinationId, + descendantsAndSelf, + replacementLineage, + merge: !!options.merge, + }; + + if (options.merge) { + JsDocs.writeDoc(options, { + _id: sourceDoc._id, + _rev: sourceDoc._rev, + _deleted: true, + }); } - info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); - }; + const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; + await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); + + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedDescendants = replaceLineageInContacts(options, moveContext); + + const ancestors = await DataSource.getAncestorsOf(db, sourceDoc); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); + + minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]); + + const movedReportsCount = await updateReports(db, options, moveContext); + trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); + + affectedContactCount += updatedDescendants.length + updatedAncestors.length; + affectedReportCount += movedReportsCount; + + info(`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + } + + info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); } -async function moveReports(db, options, moveContext) { +async function updateReports(db, options, moveContext) { const descendantIds = moveContext.descendantsAndSelf.map(contact => contact._id); let skip = 0; @@ -70,8 +69,8 @@ async function moveReports(db, options, moveContext) { const createdAtId = options.merge && moveContext.sourceId; reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip); - const lineageUpdates = replaceLineageInReports(options, reportDocsBatch, moveContext); - const reassignUpdates = reassignReports(options, reportDocsBatch, moveContext); + const lineageUpdates = replaceCreatorLineageInReports(reportDocsBatch, moveContext); + const reassignUpdates = reassignReports(reportDocsBatch, moveContext); const updatedReports = reportDocsBatch.filter(doc => lineageUpdates.has(doc._id) || reassignUpdates.has(doc._id)); minifyLineageAndWriteToDisk(options, updatedReports); @@ -82,28 +81,34 @@ async function moveReports(db, options, moveContext) { return skip; } -function reassignReports(options, reports, { sourceId, destinationId }) { - function reassignReportWithSubject(report, subjectId) { +function reassignReportSubjects(report, { sourceId, destinationId }) { + const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + let updated = false; + for (const subjectId of SUBJECT_IDS) { if (report[subjectId] === sourceId) { report[subjectId] = destinationId; - updated.add(report._id); + updated = true; } if (report.fields[subjectId] === sourceId) { report.fields[subjectId] = destinationId; - updated.add(report._id); + updated = true; } } + + return updated; +} +function reassignReports(reports, moveContext) { const updated = new Set(); - if (!options.merge) { + if (!moveContext.merge) { return updated; } for (const report of reports) { - const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; - for (const subjectId of subjectIds) { - reassignReportWithSubject(report, subjectId); + const isUpdated = reassignReportSubjects(report, moveContext); + if (isUpdated) { + updated.add(report._id); } } @@ -117,16 +122,16 @@ function minifyLineageAndWriteToDisk(options, docs) { }); } -function replaceLineageInReports(options, reports, moveContext) { - const replaceLineageOptions = { +function replaceCreatorLineageInReports(reports, moveContext) { + const replaceContactLineage = doc => lineageManipulation.replaceContactLineage(doc, { replaceWith: moveContext.replacementLineage, startingFromId: moveContext.sourceId, - merge: options.merge, - }; + merge: moveContext.merge, + }); const updates = new Set(); reports.forEach(doc => { - if (lineageManipulation.replaceContactLineage(doc, replaceLineageOptions)) { + if (replaceContactLineage(doc)) { updates.add(doc._id); } }); @@ -147,31 +152,31 @@ function replaceLineageInAncestors(descendantsAndSelf, ancestors) { return updatedAncestors; } -function replaceLineageInContacts(options, moveContext) { +function replaceForSingleContact(doc, moveContext) { const { sourceId } = moveContext; - function replaceForSingleContact(doc) { - const docIsDestination = doc._id === sourceId; - const startingFromId = options.merge || !docIsDestination ? sourceId : undefined; - const replaceLineageOptions = { - replaceWith: moveContext.replacementLineage, - startingFromId, - merge: options.merge, - }; - const parentWasUpdated = lineageManipulation.replaceParentLineage(doc, replaceLineageOptions); + const docIsDestination = doc._id === sourceId; + const startingFromId = moveContext.merge || !docIsDestination ? sourceId : undefined; + const replaceLineageOptions = { + replaceWith: moveContext.replacementLineage, + startingFromId, + merge: moveContext.merge, + }; + const parentWasUpdated = lineageManipulation.replaceParentLineage(doc, replaceLineageOptions); - replaceLineageOptions.startingFromId = sourceId; - const contactWasUpdated = lineageManipulation.replaceContactLineage(doc, replaceLineageOptions); - if (parentWasUpdated || contactWasUpdated) { - return doc; - } + replaceLineageOptions.startingFromId = sourceId; + const contactWasUpdated = lineageManipulation.replaceContactLineage(doc, replaceLineageOptions); + if (parentWasUpdated || contactWasUpdated) { + return doc; } +} +function replaceLineageInContacts(options, moveContext) { function sonarQubeComplexityFiveIsTooLow(doc) { - const docIsSource = doc._id === sourceId; + const docIsSource = doc._id === moveContext.sourceId; // skip source because it will be deleted if (!options.merge || !docIsSource) { - return replaceForSingleContact(doc); + return replaceForSingleContact(doc, moveContext); } } @@ -189,8 +194,8 @@ function replaceLineageInContacts(options, moveContext) { module.exports = (db, options) => { return { HIERARCHY_ROOT: DataSource.HIERARCHY_ROOT, - move: moveHierarchy(db, { ...options, merge: false }), - merge: moveHierarchy(db, { ...options, merge: true }), + move: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: false }, sourceIds, destinationId), + merge: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: true }, sourceIds, destinationId), }; }; diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index a2ff9fd62..f2478aacc 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -51,36 +51,36 @@ Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { - function getContactTypeError() { - const sourceContactType = getContactType(sourceDoc); - const destinationType = getContactType(destinationDoc); - const rulesForContact = mapTypeToAllowedParents[sourceContactType]; - if (!rulesForContact) { - return `cannot move contact with unknown type '${sourceContactType}'`; - } + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + const contactTypeError = getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc); + const circularHierarchyError = findCircularHierarchyErrors(sourceDoc, destinationDoc); + return commonViolations || contactTypeError || circularHierarchyError; +}; - const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; - if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { - return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; - } +function getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc) { + const sourceContactType = getContactType(sourceDoc); + const destinationType = getContactType(destinationDoc); + const rulesForContact = mapTypeToAllowedParents[sourceContactType]; + if (!rulesForContact) { + return `cannot move contact with unknown type '${sourceContactType}'`; } - function findCircularHierarchyErrors() { - if (!destinationDoc || !sourceDoc._id) { - return; - } + const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; + if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { + return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; + } +} - const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; - if (parentAncestry.includes(sourceDoc._id)) { - return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; - } +function findCircularHierarchyErrors(sourceDoc, destinationDoc) { + if (!destinationDoc || !sourceDoc._id) { + return; } - const commonViolations = getCommonViolations(sourceDoc, destinationDoc); - const contactTypeError = getContactTypeError(); - const circularHierarchyError = findCircularHierarchyErrors(); - return commonViolations || contactTypeError || circularHierarchyError; -}; + const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; + if (parentAncestry.includes(sourceDoc._id)) { + return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; + } +} const getCommonViolations = (sourceDoc, destinationDoc) => { const sourceContactType = getContactType(sourceDoc); From 99745c6e4d439ec7a493145615059a1bee2f2b6f Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 21:06:22 -0800 Subject: [PATCH 45/66] Last code review feedback --- .../hierarchy-data-source.js | 3 +- src/lib/hierarchy-operations/index.js | 36 +++++++------------ 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index fe912cf5e..da78ef7e2 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -49,7 +49,8 @@ async function getContactWithDescendants(db, contactId) { return descendantDocs.rows .map(row => row.doc) - /* We should not move or update tombstone documents */ + // We should not move or update tombstone documents + // Not relevant for 4.x cht-core versions, but needed in older versions. .filter(doc => doc && doc.type !== 'tombstone'); } diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 47d4d0177..914fe8951 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -69,7 +69,7 @@ async function updateReports(db, options, moveContext) { const createdAtId = options.merge && moveContext.sourceId; reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip); - const lineageUpdates = replaceCreatorLineageInReports(reportDocsBatch, moveContext); + const lineageUpdates = replaceLineageOfReportCreator(reportDocsBatch, moveContext); const reassignUpdates = reassignReports(reportDocsBatch, moveContext); const updatedReports = reportDocsBatch.filter(doc => lineageUpdates.has(doc._id) || reassignUpdates.has(doc._id)); @@ -115,6 +115,7 @@ function reassignReports(reports, moveContext) { return updated; } +// This ensures all documents written are fully minified. Some docs in CouchDB are not minified to start with. function minifyLineageAndWriteToDisk(options, docs) { docs.forEach(doc => { lineageManipulation.minifyLineagesInDoc(doc); @@ -122,7 +123,7 @@ function minifyLineageAndWriteToDisk(options, docs) { }); } -function replaceCreatorLineageInReports(reports, moveContext) { +function replaceLineageOfReportCreator(reports, moveContext) { const replaceContactLineage = doc => lineageManipulation.replaceContactLineage(doc, { replaceWith: moveContext.replacementLineage, startingFromId: moveContext.sourceId, @@ -152,10 +153,14 @@ function replaceLineageInAncestors(descendantsAndSelf, ancestors) { return updatedAncestors; } -function replaceForSingleContact(doc, moveContext) { +function replaceLineageInSingleContact(doc, moveContext) { const { sourceId } = moveContext; - const docIsDestination = doc._id === sourceId; - const startingFromId = moveContext.merge || !docIsDestination ? sourceId : undefined; + const docIsSource = doc._id === moveContext.sourceId; + if (docIsSource && moveContext.merge) { + return; + } + + const startingFromId = moveContext.merge || !docIsSource ? sourceId : undefined; const replaceLineageOptions = { replaceWith: moveContext.replacementLineage, startingFromId, @@ -171,24 +176,9 @@ function replaceForSingleContact(doc, moveContext) { } function replaceLineageInContacts(options, moveContext) { - function sonarQubeComplexityFiveIsTooLow(doc) { - const docIsSource = doc._id === moveContext.sourceId; - - // skip source because it will be deleted - if (!options.merge || !docIsSource) { - return replaceForSingleContact(doc, moveContext); - } - } - - const result = []; - for (const doc of moveContext.descendantsAndSelf) { - const updatedDoc = sonarQubeComplexityFiveIsTooLow(doc); - if (updatedDoc) { - result.push(updatedDoc); - } - } - - return result; + return moveContext.descendantsAndSelf + .map(descendant => replaceLineageInSingleContact(descendant, moveContext)) + .filter(Boolean); } module.exports = (db, options) => { From d4dcd455ec30ceb0d88b7cc6231dbc75cf9bac06 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 21:49:30 -0800 Subject: [PATCH 46/66] No function nesting --- src/fn/upload-docs.js | 102 ++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 1595e0199..05e062e60 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -14,7 +14,7 @@ const { info, trace, warn } = log; const FILE_EXTENSION = '.doc.json'; const INITIAL_BATCH_SIZE = 100; -const execute = async () => { +async function execute() { const args = minimist(environment.extraArgs || [], { boolean: true }); const docDir = path.resolve(environment.pathToProject, args.docDirectoryPath || 'json_docs'); @@ -101,10 +101,10 @@ const execute = async () => { }; return processNextBatch(filenamesToUpload, INITIAL_BATCH_SIZE); -}; +} -const preuploadAnalysis = filePaths => - filePaths +function preuploadAnalysis(filePaths) { + return filePaths .map(filePath => { const json = fs.readJson(filePath); const idFromFilename = path.basename(filePath, FILE_EXTENSION); @@ -118,67 +118,71 @@ const preuploadAnalysis = filePaths => } }) .filter(Boolean); +} + -const handleUsersAtDeletedFacilities = async deletedDocIds => { - const affectedUsers = await getAffectedUsers(); + +async function handleUsersAtDeletedFacilities(deletedDocIds) { + const affectedUsers = await getAffectedUsers(deletedDocIds); const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); warn(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`); if (affectedUsers.length === 0 || !userPrompt.keyInYN()) { return; } - await updateAffectedUsers(); + await updateAffectedUsers(affectedUsers); +} - async function getAffectedUsers() { - const knownUserDocs = {}; - for (const facilityId of deletedDocIds) { - const fetchedUserInfos = await api().getUsersAtPlace(facilityId); - for (const fetchedUserInfo of fetchedUserInfos) { - const userDoc = knownUserDocs[fetchedUserInfo.username] || toPostApiFormat(fetchedUserInfo); - removePlace(userDoc, facilityId); - knownUserDocs[userDoc.username] = userDoc; - } - } - return Object.values(knownUserDocs); +async function getAffectedUsers(deletedDocIds) { + const knownUserDocs = {}; + for (const facilityId of deletedDocIds) { + const fetchedUserInfos = await api().getUsersAtPlace(facilityId); + for (const fetchedUserInfo of fetchedUserInfos) { + const userDoc = knownUserDocs[fetchedUserInfo.username] || toPostApiFormat(fetchedUserInfo); + removePlace(userDoc, facilityId); + knownUserDocs[userDoc.username] = userDoc; + } } - function toPostApiFormat(apiResponse) { - return { - _id: apiResponse.id, - _rev: apiResponse.rev, - username: apiResponse.username, - place: apiResponse.place?.filter(Boolean).map(place => place._id), - }; - } + return Object.values(knownUserDocs); +} - function removePlace(userDoc, placeId) { - if (Array.isArray(userDoc.place)) { - userDoc.place = userDoc.place - .filter(id => id !== placeId); +function toPostApiFormat(apiResponse) { + return { + _id: apiResponse.id, + _rev: apiResponse.rev, + username: apiResponse.username, + place: apiResponse.place?.filter(Boolean).map(place => place._id), + }; +} + +function removePlace(userDoc, placeId) { + if (Array.isArray(userDoc.place)) { + userDoc.place = userDoc.place + .filter(id => id !== placeId); + } else { + delete userDoc.place; + } +} + +async function updateAffectedUsers(affectedUsers) { + let disabledUsers = 0, updatedUsers = 0; + for (const userDoc of affectedUsers) { + const shouldDisable = !userDoc.place || userDoc.place?.length === 0; + if (shouldDisable) { + trace(`Disabling ${userDoc.username}`); + await api().disableUser(userDoc.username); + disabledUsers++; } else { - delete userDoc.place; + trace(`Updating ${userDoc.username}`); + await api().updateUser(userDoc); + updatedUsers++; } } - async function updateAffectedUsers() { - let disabledUsers = 0, updatedUsers = 0; - for (const userDoc of affectedUsers) { - const shouldDisable = !userDoc.place || userDoc.place?.length === 0; - if (shouldDisable) { - trace(`Disabling ${userDoc.username}`); - await api().disableUser(userDoc.username); - disabledUsers++; - } else { - trace(`Updating ${userDoc.username}`); - await api().updateUser(userDoc); - updatedUsers++; - } - } - - info(`${disabledUsers} users disabled. ${updatedUsers} users updated.`); - } -}; + info(`${disabledUsers} users disabled. ${updatedUsers} users updated.`); +} module.exports = { requiresInstance: true, From b323c9f71102b0c2249e2a4d46c10799766aa45c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 22:10:19 -0800 Subject: [PATCH 47/66] SonarCube after refactor --- src/fn/upload-docs.js | 40 ++++++++++++++++++------------------- test/fn/upload-docs.spec.js | 3 ++- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 05e062e60..82ff738ec 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -29,16 +29,13 @@ async function execute() { return; // nothing to upload } - const analysis = preuploadAnalysis(filenamesToUpload); + const analysis = analyseFiles(filenamesToUpload); const errors = analysis.map(result => result.error).filter(Boolean); if (errors.length > 0) { throw new Error(`upload-docs: ${errors.join('\n')}`); } - warn(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`); - if (!userPrompt.keyInYN()) { - throw new Error('User aborted execution.'); - } + warnAndPrompt(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`); if (args['disable-users']) { const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); @@ -103,7 +100,14 @@ async function execute() { return processNextBatch(filenamesToUpload, INITIAL_BATCH_SIZE); } -function preuploadAnalysis(filePaths) { +function warnAndPrompt(warningMessage) { + warn(warningMessage); + if (!userPrompt.keyInYN()) { + throw new Error('User aborted execution.'); + } +} + +function analyseFiles(filePaths) { return filePaths .map(filePath => { const json = fs.readJson(filePath); @@ -120,21 +124,26 @@ function preuploadAnalysis(filePaths) { .filter(Boolean); } - - async function handleUsersAtDeletedFacilities(deletedDocIds) { const affectedUsers = await getAffectedUsers(deletedDocIds); - const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); - warn(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`); - if (affectedUsers.length === 0 || !userPrompt.keyInYN()) { + const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); + if (affectedUsers.length === 0) { return; } + warnAndPrompt(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`); await updateAffectedUsers(affectedUsers); } async function getAffectedUsers(deletedDocIds) { + const toPostApiFormat = (apiResponse) => ({ + _id: apiResponse.id, + _rev: apiResponse.rev, + username: apiResponse.username, + place: apiResponse.place?.filter(Boolean).map(place => place._id), + }); + const knownUserDocs = {}; for (const facilityId of deletedDocIds) { const fetchedUserInfos = await api().getUsersAtPlace(facilityId); @@ -148,15 +157,6 @@ async function getAffectedUsers(deletedDocIds) { return Object.values(knownUserDocs); } -function toPostApiFormat(apiResponse) { - return { - _id: apiResponse.id, - _rev: apiResponse.rev, - username: apiResponse.username, - place: apiResponse.place?.filter(Boolean).map(place => place._id), - }; -} - function removePlace(userDoc, placeId) { if (Array.isArray(userDoc.place)) { userDoc.place = userDoc.place diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 44dc5cf77..8dd57c556 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -136,7 +136,7 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); - describe('kenn --disable-users', () => { + describe('--disable-users', () => { beforeEach(async () => { sinon.stub(environment, 'extraArgs').get(() => ['--disable-users']); await assertDbEmpty(); @@ -236,6 +236,7 @@ async function setupDeletedFacilities(...docIds) { const expected = expectedDocs.find(doc => doc._id === id); expected._rev = writtenDoc.rev; expected._deleted = true; + expected.disableUsers = true; } } From 5c8f83cdfa54340b5298d59c38ec8745517d60ca Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 22:47:31 -0800 Subject: [PATCH 48/66] Only disable users at places --- src/fn/upload-docs.js | 2 +- .../hierarchy-operations/delete-hierarchy.js | 4 +- src/lib/hierarchy-operations/index.js | 5 +-- src/lib/hierarchy-operations/jsdocFolder.js | 3 +- .../lineage-constraints.js | 39 +++++++++++-------- test/fn/upload-docs.spec.js | 12 ++++++ .../hierarchy-operations.spec.js | 26 +++++++++---- 7 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 82ff738ec..5ff50f2e3 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -117,7 +117,7 @@ function analyseFiles(filePaths) { return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` }; } - if (json._deleted) { + if (json._deleted && json.disableUsers) { return { delete: json._id }; } }) diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index d6bfcbe76..8ff882123 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -1,10 +1,12 @@ const DataSource = require('./hierarchy-data-source'); const JsDocs = require('./jsdocFolder'); +const lineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; async function deleteHierarchy(db, options, sourceIds) { const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); + const constraints = await lineageConstraints(db, options); for (const sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; trace(`Deleting descendants and reports under: ${prettyPrintDocument(sourceDoc)}`); @@ -12,7 +14,7 @@ async function deleteHierarchy(db, options, sourceIds) { let affectedReportCount = 0; for (const descendant of descendantsAndSelf) { - JsDocs.deleteDoc(options, descendant); + JsDocs.deleteDoc(options, descendant, constraints.isPlace(descendant)); affectedReportCount += await deleteReportsForContact(db, options, descendant); } diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index dca797a7b..a3e4f8893 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -5,9 +5,6 @@ const lineageManipulation = require('./lineage-manipulation'); const LineageConstraints = require('./lineage-constraints'); const { trace, info } = require('../log'); -const JsDocs = require('./jsdocFolder'); -const DataSource = require('./hierarchy-data-source'); - async function moveHierarchy(db, options, sourceIds, destinationId) { JsDocs.prepareFolder(options); trace(`Fetching contact details: ${destinationId}`); @@ -31,7 +28,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { }; if (options.merge) { - JsDocs.deleteDoc(options, sourceDoc); + JsDocs.deleteDoc(options, sourceDoc, constraints.isPlace(sourceDoc)); } const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js index 2e220740d..6317411c5 100644 --- a/src/lib/hierarchy-operations/jsdocFolder.js +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -30,11 +30,12 @@ function deleteAfterConfirmation(docDirectoryPath) { fs.deleteFilesInFolder(docDirectoryPath); } -function deleteDoc(options, doc) { +function deleteDoc(options, doc, disableUsers) { writeDoc(options, { _id: doc._id, _rev: doc._rev, _deleted: true, + disableUsers: !!disableUsers, }); } diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index f2478aacc..e665423e0 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -5,7 +5,7 @@ const { trace } = log; const lineageManipulation = require('./lineage-manipulation'); module.exports = async (db, options) => { - const mapTypeToAllowedParents = await fetchAllowedParents(db); + const contactTypeInfo = await fetchContactTypeInfo(db); return { assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => { @@ -22,7 +22,7 @@ module.exports = async (db, options) => { sourceDocs.forEach(sourceDoc => { const hierarchyError = options.merge ? getMergeViolations(sourceDoc, destinationDoc) - : getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc); + : getMovingViolations(contactTypeInfo, sourceDoc, destinationDoc); if (hierarchyError) { throw Error(`Hierarchy Constraints: ${hierarchyError}`); @@ -42,7 +42,12 @@ module.exports = async (db, options) => { throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); } }); - } + }, + + isPlace: (contact) => { + const contactType = getContactType(contact); + return !contactTypeInfo[contactType]?.person; + }, }; }; @@ -50,23 +55,23 @@ module.exports = async (db, options) => { Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ -const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { +const getMovingViolations = (contactTypeInfo, sourceDoc, destinationDoc) => { const commonViolations = getCommonViolations(sourceDoc, destinationDoc); - const contactTypeError = getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc); + const contactTypeError = getMovingContactTypeError(contactTypeInfo, sourceDoc, destinationDoc); const circularHierarchyError = findCircularHierarchyErrors(sourceDoc, destinationDoc); return commonViolations || contactTypeError || circularHierarchyError; }; -function getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc) { +function getMovingContactTypeError(contactTypeInfo, sourceDoc, destinationDoc) { const sourceContactType = getContactType(sourceDoc); const destinationType = getContactType(destinationDoc); - const rulesForContact = mapTypeToAllowedParents[sourceContactType]; - if (!rulesForContact) { + const parentsForContactType = contactTypeInfo[sourceContactType]?.parents; + if (!parentsForContactType) { return `cannot move contact with unknown type '${sourceContactType}'`; } - const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; - if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { + const isPermittedMoveToRoot = !destinationDoc && parentsForContactType.length === 0; + if (!isPermittedMoveToRoot && !parentsForContactType.includes(destinationType)) { return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; } } @@ -138,9 +143,9 @@ const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, desce return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); }; -const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); +const getContactType = doc => doc?.type === 'contact' ? doc?.contact_type : doc?.type; -async function fetchAllowedParents(db) { +async function fetchContactTypeInfo(db) { try { const { settings: { contact_types } } = await db.get('settings'); @@ -149,7 +154,7 @@ async function fetchAllowedParents(db) { const parentDict = {}; contact_types .filter(Boolean) - .forEach(({ id, parents }) => parentDict[id] = parents); + .forEach(({ id, person, parents }) => parentDict[id] = { parents, person: !!person }); return parentDict; } } catch (err) { @@ -160,10 +165,10 @@ async function fetchAllowedParents(db) { trace('Default hierarchy constraints will be enforced.'); return { - district_hospital: [], - health_center: ['district_hospital'], - clinic: ['health_center'], - person: ['district_hospital', 'health_center', 'clinic'], + district_hospital: { parents: [] }, + health_center: { parents: ['district_hospital'] }, + clinic: { parents: ['health_center'] }, + person: { parents: ['district_hospital', 'health_center', 'clinic'], person: true }, }; } diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 8dd57c556..94de5c7b4 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -161,6 +161,18 @@ describe('upload-docs', function() { ]); }); + it('users associated with docs without truthy deleteUser attribute are not deleted', async () => { + const writtenDoc = await apiStub.db.put({ _id: 'one' }); + const oneDoc = expectedDocs[0]; + oneDoc._rev = writtenDoc.rev; + oneDoc._deleted = true; + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + assert.deepEqual(apiStub.requestLog(), []); + }); + it('user with multiple places gets updated', async () => { await setupDeletedFacilities('one'); setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }]); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index db4db8ad6..a437ebf5f 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -678,6 +678,7 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('district_2')).to.deep.eq({ _id: 'district_2', _deleted: true, + disableUsers: true, }); expect(getWrittenDoc('health_center_2')).to.deep.eq({ @@ -756,6 +757,7 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('patient_2')).to.deep.eq({ _id: 'patient_2', _deleted: true, + disableUsers: false, }); expect(getWrittenDoc('pat2')).to.deep.eq({ @@ -772,10 +774,11 @@ describe('hierarchy-operations', () => { }); describe('delete', () => { - const expectDelted = id => { + const expectDeleted = (id, disableUsers = false) => { expect(getWrittenDoc(id)).to.deep.eq({ _id: id, _deleted: true, + disableUsers, }); }; @@ -797,15 +800,22 @@ describe('hierarchy-operations', () => { await HierarchyOperations(pouchDb).delete(['district_2']); // assert - const deletedDocIds = [ - 'district_2', 'district_2_contact', - 'health_center_2', 'health_center_2_contact', - 'clinic_2', 'clinic_2_contact', + const deletedPlaces = [ + 'district_2', + 'health_center_2', + 'clinic_2', + ]; + const deletedNonPeople = [ + 'district_2_contact', + 'health_center_2_contact', + 'clinic_2_contact', 'patient_2', - 'district_report', 'patient_report', + 'district_report', + 'patient_report', ]; - expectWrittenDocs(deletedDocIds); - deletedDocIds.forEach(id => expectDelted(id)); + expectWrittenDocs([...deletedPlaces, ...deletedNonPeople]); + deletedPlaces.forEach(id => expectDeleted(id, true)); + deletedNonPeople.forEach(id => expectDeleted(id, false)); }); it('reports created by deleted contacts are not deleted', async () => { From 9685122551b209adbab8ce17aba6ac4a6c2a31c4 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 22:50:02 -0800 Subject: [PATCH 49/66] Docs were missing --- src/fn/delete-contacts.js | 3 +++ src/fn/merge-contacts.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/fn/delete-contacts.js b/src/fn/delete-contacts.js index 09a682efd..bcc48f20e 100644 --- a/src/fn/delete-contacts.js +++ b/src/fn/delete-contacts.js @@ -53,6 +53,9 @@ ${bold('OPTIONS')} --ids=, A comma delimited list of ids of contacts to be deleted. +--disable-users + When flag is present, users at to any deleted place will be permanently disabled. + --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 6140d6726..462b36936 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -63,6 +63,9 @@ ${bold('OPTIONS')} --sources=, A comma delimited list of IDs of contacts which will be deleted. The hierarchy of contacts and reports under it will be moved to be under the destination contact. +--disable-users + When flag is present, users at to any deleted place will be permanently disabled. + --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); From dcff03e757bedb691054a192f38de8b87a167a06 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Sun, 8 Dec 2024 22:58:54 -0800 Subject: [PATCH 50/66] Move flag onto the functions not upload-docs --- src/fn/delete-contacts.js | 1 + src/fn/merge-contacts.js | 1 + src/fn/upload-docs.js | 7 +++---- src/lib/hierarchy-operations/delete-hierarchy.js | 3 ++- src/lib/hierarchy-operations/index.js | 3 ++- test/fn/merge-contacts.spec.js | 1 + .../hierarchy-operations/hierarchy-operations.spec.js | 9 +++++++-- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/fn/delete-contacts.js b/src/fn/delete-contacts.js index bcc48f20e..87c7a1f22 100644 --- a/src/fn/delete-contacts.js +++ b/src/fn/delete-contacts.js @@ -35,6 +35,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { return { sourceIds, + disableUsers: !!args['disable-users'], docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 462b36936..41bae5896 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -41,6 +41,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { return { destinationId: args.destination, sourceIds, + disableUsers: !!args['disable-users'], docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 5ff50f2e3..2db6e1b3d 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -37,10 +37,8 @@ async function execute() { warnAndPrompt(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`); - if (args['disable-users']) { - const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); - await handleUsersAtDeletedFacilities(deletedDocIds); - } + const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); + await handleUsersAtDeletedFacilities(deletedDocIds); const results = { ok:[], failed:{} }; const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null; @@ -128,6 +126,7 @@ async function handleUsersAtDeletedFacilities(deletedDocIds) { const affectedUsers = await getAffectedUsers(deletedDocIds); const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); if (affectedUsers.length === 0) { + trace('No deleted places with potential users found.'); return; } diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index 8ff882123..ec3cff7a9 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -14,7 +14,8 @@ async function deleteHierarchy(db, options, sourceIds) { let affectedReportCount = 0; for (const descendant of descendantsAndSelf) { - JsDocs.deleteDoc(options, descendant, constraints.isPlace(descendant)); + const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant); + JsDocs.deleteDoc(options, descendant, toDeleteUsers); affectedReportCount += await deleteReportsForContact(db, options, descendant); } diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index a3e4f8893..21aacf8fe 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -28,7 +28,8 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { }; if (options.merge) { - JsDocs.deleteDoc(options, sourceDoc, constraints.isPlace(sourceDoc)); + const toDeleteUsers = options.disableUsers && constraints.isPlace(sourceDoc); + JsDocs.deleteDoc(options, sourceDoc, toDeleteUsers); } const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index fbb8ec6fe..a1b0d9942 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -18,6 +18,7 @@ describe('merge-contacts', () => { expect(parseExtraArgs(__dirname, args)).to.deep.eq({ sourceIds: ['food', 'is', 'tasty'], destinationId: 'bar', + disableUsers: false, force: true, docDirectoryPath: '/', }); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index a437ebf5f..1c8268043 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -664,7 +664,7 @@ describe('hierarchy-operations', () => { }); // action - await HierarchyOperations(pouchDb).merge(['district_2'], 'district_1'); + await HierarchyOperations(pouchDb, { disableUsers: true }).merge(['district_2'], 'district_1'); // assert expectWrittenDocs([ @@ -797,7 +797,7 @@ describe('hierarchy-operations', () => { }); // action - await HierarchyOperations(pouchDb).delete(['district_2']); + await HierarchyOperations(pouchDb, { disableUsers: true }).delete(['district_2']); // assert const deletedPlaces = [ @@ -818,6 +818,11 @@ describe('hierarchy-operations', () => { deletedNonPeople.forEach(id => expectDeleted(id, false)); }); + it('users at are not disabled when disableUsers: false', async () => { + await HierarchyOperations(pouchDb, { disableUsers: false }).delete(['district_2']); + expectDeleted('district_2', false); + }); + it('reports created by deleted contacts are not deleted', async () => { // setup await mockReport(pouchDb, { From cac87f3ebdf63a5ea841783c08cc07d7e9255c2f Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 01:12:37 -0800 Subject: [PATCH 51/66] Assert if core version is insufficient --- src/fn/delete-contacts.js | 1 + src/fn/merge-contacts.js | 1 + src/fn/upload-docs.js | 30 +++++++++++++---- src/lib/api.js | 7 ++-- .../hierarchy-operations/delete-hierarchy.js | 2 ++ test/fn/upload-docs.spec.js | 32 ++++++++++++++++++- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/fn/delete-contacts.js b/src/fn/delete-contacts.js index 87c7a1f22..133d4342a 100644 --- a/src/fn/delete-contacts.js +++ b/src/fn/delete-contacts.js @@ -15,6 +15,7 @@ module.exports = { const options = { docDirectoryPath: args.docDirectoryPath, force: args.force, + disableUsers: args.disableUsers, }; return HierarchyOperations(db, options).delete(args.sourceIds); } diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 41bae5896..edd3c9eba 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -13,6 +13,7 @@ module.exports = { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); const options = { + disableUsers: args.disableUsers, docDirectoryPath: args.docDirectoryPath, force: args.force, }; diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 2db6e1b3d..2a2468776 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -1,9 +1,11 @@ const path = require('path'); const minimist = require('minimist'); +const semver = require('semver'); const api = require('../lib/api'); const environment = require('../lib/environment'); const fs = require('../lib/sync-fs'); +const { getValidApiVersion } = require('../lib/get-api-version'); const log = require('../lib/log'); const pouch = require('../lib/db'); const progressBar = require('../lib/progress-bar'); @@ -123,10 +125,12 @@ function analyseFiles(filePaths) { } async function handleUsersAtDeletedFacilities(deletedDocIds) { + await assertCoreVersion(); + const affectedUsers = await getAffectedUsers(deletedDocIds); const usernames = affectedUsers.map(userDoc => userDoc.username).join(', '); if (affectedUsers.length === 0) { - trace('No deleted places with potential users found.'); + trace('No users found needing an update.'); return; } @@ -134,14 +138,26 @@ async function handleUsersAtDeletedFacilities(deletedDocIds) { await updateAffectedUsers(affectedUsers); } +async function assertCoreVersion() { + const actualCoreVersion = await getValidApiVersion(); + if (semver.lt(actualCoreVersion, '4.7.0-dev')) { + throw Error(`CHT Core Version 4.7.0 or newer is required to use --disable-users options. Version is ${actualCoreVersion}.`); + } + + trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`) +} async function getAffectedUsers(deletedDocIds) { - const toPostApiFormat = (apiResponse) => ({ - _id: apiResponse.id, - _rev: apiResponse.rev, - username: apiResponse.username, - place: apiResponse.place?.filter(Boolean).map(place => place._id), - }); + const toPostApiFormat = (apiResponse) => { + const places = Array.isArray(apiResponse.place) ? apiResponse.place.filter(Boolean) : [apiResponse.place]; + const placeIds = places.map(place => place?._id); + return { + _id: apiResponse.id, + _rev: apiResponse.rev, + username: apiResponse.username, + place: placeIds, + }; + }; const knownUserDocs = {}; for (const facilityId of deletedDocIds) { diff --git a/src/lib/api.js b/src/lib/api.js index e11a96a8a..9035c29eb 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -170,9 +170,10 @@ const api = { } }, - version() { - return request.get({ uri: `${environment.instanceUrl}/api/deploy-info`, json: true }) // endpoint added in 3.5 - .then(deploy_info => deploy_info && deploy_info.version); + async version() { + // endpoint added in 3.5 + const response = await request.get({ uri: `${environment.instanceUrl}/api/deploy-info`, json: true }); + return response.deploy_info?.version; }, /** diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index ec3cff7a9..4d9f470c9 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -5,6 +5,8 @@ const { trace, info } = require('../log'); const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; async function deleteHierarchy(db, options, sourceIds) { + JsDocs.prepareFolder(options); + const sourceDocs = await DataSource.getContactsByIds(db, sourceIds); const constraints = await lineageConstraints(db, options); for (const sourceId of sourceIds) { diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 94de5c7b4..514e88ae5 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -12,6 +12,8 @@ uploadDocs.__set__('userPrompt', userPrompt); let fs, expectedDocs; +const API_VERSION_RESPONSE = { status: 200, body: { deploy_info: { version: '4.10.0'} }}; + describe('upload-docs', function() { beforeEach(() => { sinon.stub(environment, 'isArchiveMode').get(() => false); @@ -41,6 +43,8 @@ describe('upload-docs', function() { }); it('should upload docs to pouch', async () => { + apiStub.giveResponses(API_VERSION_RESPONSE); + await assertDbEmpty(); await uploadDocs.execute(); const res = await apiStub.db.allDocs(); @@ -82,6 +86,7 @@ describe('upload-docs', function() { expectedDocs = new Array(10).fill('').map((x, i) => ({ _id: i.toString() })); const clock = sinon.useFakeTimers(0); const imported_date = new Date().toISOString(); + apiStub.giveResponses(API_VERSION_RESPONSE); return uploadDocs.__with__({ INITIAL_BATCH_SIZE: 4, Date, @@ -128,6 +133,7 @@ describe('upload-docs', function() { }); it('should not throw if force is set', async () => { + apiStub.giveResponses(API_VERSION_RESPONSE); userPrompt.__set__('environment', { force: () => true }); await assertDbEmpty(); sinon.stub(process, 'exit'); @@ -156,6 +162,22 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + ]); + }); + + it('user with single place gets deleted (old core api format)', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: { _id: 'one' } }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, ]); @@ -163,6 +185,8 @@ describe('upload-docs', function() { it('users associated with docs without truthy deleteUser attribute are not deleted', async () => { const writtenDoc = await apiStub.db.put({ _id: 'one' }); + apiStub.giveResponses(API_VERSION_RESPONSE); + const oneDoc = expectedDocs[0]; oneDoc._rev = writtenDoc.rev; oneDoc._deleted = true; @@ -170,7 +194,9 @@ describe('upload-docs', function() { await uploadDocs.execute(); const res = await apiStub.db.allDocs(); expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); - assert.deepEqual(apiStub.requestLog(), []); + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} } + ]); }); it('user with multiple places gets updated', async () => { @@ -187,6 +213,7 @@ describe('upload-docs', function() { place: [ 'two' ], }; assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'POST', url: '/api/v1/users/user1', body: expectedBody }, ]); @@ -202,6 +229,7 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['three']); assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=two', body: {} }, { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, @@ -225,6 +253,7 @@ describe('upload-docs', function() { place: ['two'], }; assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, { method: 'POST', url: '/api/v1/users/user2', body: expectedUser2 }, @@ -237,6 +266,7 @@ function setupApiResponses(writeCount, ...userDocResponseRows) { const responseBodies = userDocResponseRows.map(body => ({ body })); const writeResponses = new Array(writeCount).fill({ status: 200 }); apiStub.giveResponses( + API_VERSION_RESPONSE, ...responseBodies, ...writeResponses, ); From e88cc239c47b3fa97a3a7dec036e66d8803cbb5e Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 01:13:44 -0800 Subject: [PATCH 52/66] Missing semicolon --- src/fn/upload-docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 2a2468776..d844dcabb 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -144,7 +144,7 @@ async function assertCoreVersion() { throw Error(`CHT Core Version 4.7.0 or newer is required to use --disable-users options. Version is ${actualCoreVersion}.`); } - trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`) + trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`); } async function getAffectedUsers(deletedDocIds) { From e3a7039a15b02e05c9955706edc62ea6f04195c8 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 01:17:05 -0800 Subject: [PATCH 53/66] Revert this --- src/lib/api.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/api.js b/src/lib/api.js index 9035c29eb..e11a96a8a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -170,10 +170,9 @@ const api = { } }, - async version() { - // endpoint added in 3.5 - const response = await request.get({ uri: `${environment.instanceUrl}/api/deploy-info`, json: true }); - return response.deploy_info?.version; + version() { + return request.get({ uri: `${environment.instanceUrl}/api/deploy-info`, json: true }) // endpoint added in 3.5 + .then(deploy_info => deploy_info && deploy_info.version); }, /** From 38b6316b3cc546ec54f0b78757a224c31ca25dbd Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 12:57:54 -0800 Subject: [PATCH 54/66] First test passing --- src/fn/merge-contacts.js | 5 + .../hierarchy-operations/delete-hierarchy.js | 2 +- .../hierarchy-data-source.js | 12 +-- src/lib/hierarchy-operations/index.js | 94 +++++++++++++------ test/fn/merge-contacts.spec.js | 1 + .../hierarchy-operations.spec.js | 54 ++++++++++- 6 files changed, 130 insertions(+), 38 deletions(-) diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index edd3c9eba..ddbc61db9 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -15,6 +15,7 @@ module.exports = { const options = { disableUsers: args.disableUsers, docDirectoryPath: args.docDirectoryPath, + mergePrimaryContacts: args.mergePrimaryContacts, force: args.force, }; return HierarchyOperations(db, options).merge(args.sourceIds, args.destinationId); @@ -44,6 +45,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { sourceIds, disableUsers: !!args['disable-users'], docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), + mergePrimaryContacts: !!args['merge-primary-contacts'], force: !!args.force, }; }; @@ -68,6 +70,9 @@ ${bold('OPTIONS')} --disable-users When flag is present, users at to any deleted place will be permanently disabled. +--merge-primary-contacts + When flag is present, the primary contacts for all the top-level places will also be merged into a single resulting contact. + --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index 4d9f470c9..599f14c04 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -31,7 +31,7 @@ async function deleteReportsForContact(db, options, contact) { let skip = 0; let reportBatch; do { - reportBatch = await DataSource.getReportsForContacts(db, [], contact._id, skip); + reportBatch = await DataSource.getReportsForContacts(db, [], [contact._id], skip); for (const report of reportBatch) { JsDocs.deleteDoc(options, report); diff --git a/src/lib/hierarchy-operations/hierarchy-data-source.js b/src/lib/hierarchy-operations/hierarchy-data-source.js index da78ef7e2..c5d15d1d3 100644 --- a/src/lib/hierarchy-operations/hierarchy-data-source.js +++ b/src/lib/hierarchy-operations/hierarchy-data-source.js @@ -2,6 +2,7 @@ const lineageManipulation = require('./lineage-manipulation'); const HIERARCHY_ROOT = 'root'; const BATCH_SIZE = 10000; +const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; /* Fetches all of the documents associated with the "contactIds" and confirms they exist. @@ -54,14 +55,10 @@ async function getContactWithDescendants(db, contactId) { .filter(doc => doc && doc.type !== 'tombstone'); } -async function getReportsForContacts(db, createdByIds, createdAtId, skip) { +async function getReportsForContacts(db, createdByIds, createdAtIds, skip) { const createdByKeys = createdByIds.map(id => [`contact:${id}`]); - const createdAtKeys = createdAtId ? [ - [`patient_id:${createdAtId}`], - [`patient_uuid:${createdAtId}`], - [`place_id:${createdAtId}`], - [`place_uuid:${createdAtId}`] - ] : []; + const mapIdToSubjectKeys = id => SUBJECT_IDS.map(subjectId => [`${subjectId}:${id}`]); + const createdAtKeys = createdAtIds ? createdAtIds.map(mapIdToSubjectKeys).flat() : []; const reports = await db.query('medic-client/reports_by_freetext', { keys: [ @@ -95,6 +92,7 @@ async function getAncestorsOf(db, contactDoc) { module.exports = { BATCH_SIZE, HIERARCHY_ROOT, + SUBJECT_IDS, getAncestorsOf, getContactWithDescendants, getContact, diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 21aacf8fe..900f5853c 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -19,24 +19,23 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { for (const sourceId of sourceIds) { const sourceDoc = sourceDocs[sourceId]; const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId); + const moveContext = { sourceId, destinationId, descendantsAndSelf, replacementLineage, merge: !!options.merge, + mergePrimaryContacts: !!options.mergePrimaryContacts, + sourcePrimaryContactId: getPrimaryContactId(sourceDoc), + destinationPrimaryContactId: getPrimaryContactId(destinationDoc), }; - if (options.merge) { - const toDeleteUsers = options.disableUsers && constraints.isPlace(sourceDoc); - JsDocs.deleteDoc(options, sourceDoc, toDeleteUsers); - } - const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedDescendants = replaceLineageInContacts(options, moveContext); + trace(`Considering updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedDescendants = updateContacts(options, constraints, moveContext); const ancestors = await DataSource.getAncestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); @@ -56,6 +55,10 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); } +function getPrimaryContactId(doc) { + return typeof doc?.contact === 'string' ? doc.contact : doc?.contact?._id; +} + async function updateReports(db, options, moveContext) { const descendantIds = moveContext.descendantsAndSelf.map(contact => contact._id); @@ -63,8 +66,8 @@ async function updateReports(db, options, moveContext) { let reportDocsBatch; do { info(`Processing ${skip} to ${skip + DataSource.BATCH_SIZE} report docs`); - const createdAtId = options.merge && moveContext.sourceId; - reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip); + const createdAtIds = getReportsCreatedAtIds(moveContext); + reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtIds, skip); const lineageUpdates = replaceLineageOfReportCreator(reportDocsBatch, moveContext); const reassignUpdates = reassignReports(reportDocsBatch, moveContext); @@ -78,24 +81,47 @@ async function updateReports(db, options, moveContext) { return skip; } -function reassignReportSubjects(report, { sourceId, destinationId }) { - const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; +function getReportsCreatedAtIds(moveContext) { + const result = []; + if (moveContext.merge) { + result.push(moveContext.sourceId); + } + + if (moveContext.mergePrimaryContacts && moveContext.sourcePrimaryContactId) { + result.push(moveContext.sourcePrimaryContactId); + } + + return result; +} + +function reassignReportSubjects(report, moveContext) { let updated = false; - for (const subjectId of SUBJECT_IDS) { - if (report[subjectId] === sourceId) { - report[subjectId] = destinationId; - updated = true; - } + for (const subjectId of DataSource.SUBJECT_IDS) { + updated |= reassignSingleReport(report, subjectId, moveContext.sourceId, moveContext.destinationId); - if (report.fields[subjectId] === sourceId) { - report.fields[subjectId] = destinationId; - updated = true; + if (moveContext.mergePrimaryContacts && moveContext.sourcePrimaryContactId && moveContext.destinationPrimaryContactId) { + updated |= reassignSingleReport(report, subjectId, moveContext.sourcePrimaryContactId, moveContext.destinationPrimaryContactId); } } return updated; } +function reassignSingleReport(report, subjectId, matchId, resultingId) { + let result = false; + if (report[subjectId] === matchId) { + report[subjectId] = resultingId; + result = true; + } + + if (report.fields[subjectId] === matchId) { + report.fields[subjectId] = resultingId; + result = true; + } + + return result; +} + function reassignReports(reports, moveContext) { const updated = new Set(); if (!moveContext.merge) { @@ -150,14 +176,9 @@ function replaceLineageInAncestors(descendantsAndSelf, ancestors) { return updatedAncestors; } -function replaceLineageInSingleContact(doc, moveContext) { - const { sourceId } = moveContext; +function replaceLineageInSingleContact(doc, moveContext) { const docIsSource = doc._id === moveContext.sourceId; - if (docIsSource && moveContext.merge) { - return; - } - - const startingFromId = moveContext.merge || !docIsSource ? sourceId : undefined; + const startingFromId = moveContext.merge || !docIsSource ? moveContext.sourceId : undefined; const replaceLineageOptions = { replaceWith: moveContext.replacementLineage, startingFromId, @@ -165,16 +186,31 @@ function replaceLineageInSingleContact(doc, moveContext) { }; const parentWasUpdated = lineageManipulation.replaceParentLineage(doc, replaceLineageOptions); - replaceLineageOptions.startingFromId = sourceId; + replaceLineageOptions.startingFromId = moveContext.sourceId; const contactWasUpdated = lineageManipulation.replaceContactLineage(doc, replaceLineageOptions); if (parentWasUpdated || contactWasUpdated) { return doc; } } -function replaceLineageInContacts(options, moveContext) { +function updateContacts(options, constraints, moveContext) { return moveContext.descendantsAndSelf - .map(descendant => replaceLineageInSingleContact(descendant, moveContext)) + .map(descendant => { + const toDelete = (moveContext.merge && descendant._id === moveContext.sourceId) || + (moveContext.mergePrimaryContacts && descendant._id === moveContext.sourcePrimaryContactId); + + if (toDelete) { + const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant); + return { + _id: descendant._id, + _rev: descendant._rev, + _deleted: true, + disableUsers: !!toDeleteUsers, + }; + } + + return replaceLineageInSingleContact(descendant, moveContext); + }) .filter(Boolean); } diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index a1b0d9942..aba915e7c 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -19,6 +19,7 @@ describe('merge-contacts', () => { sourceIds: ['food', 'is', 'tasty'], destinationId: 'bar', disableUsers: false, + mergePrimaryContacts: false, force: true, docDirectoryPath: '/', }); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 1c8268043..fd7183255 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -644,6 +644,14 @@ describe('hierarchy-operations', () => { }); describe('merge', () => { + beforeEach(async () => { + await mockReport(pouchDb, { + id: 'district_primary_contact_report', + creatorId: 'district_2_contact', + patientId: 'district_2_contact' + }); + }); + it('merge district_2 into district_1', async () => { // setup await mockReport(pouchDb, { @@ -672,7 +680,7 @@ describe('hierarchy-operations', () => { 'health_center_2', 'health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2', - 'changing_subject_and_contact', 'changing_contact', 'changing_subject' + 'changing_subject_and_contact', 'changing_contact', 'changing_subject', 'district_primary_contact_report' ]); expect(getWrittenDoc('district_2')).to.deep.eq({ @@ -733,6 +741,17 @@ describe('hierarchy-operations', () => { patient_uuid: 'district_1' } }); + + expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ + _id: 'district_primary_contact_report', + form: 'foo', + type: 'person', + type: 'data_record', + contact: parentsToLineage('district_2_contact', 'district_1'), + fields: { + patient_uuid: 'district_2_contact' + } + }); }); it('merge two patients', async () => { @@ -771,6 +790,39 @@ describe('hierarchy-operations', () => { } }); }); + + it('--merge-primary-contacts results in merge of primary contacts and reports', async () => { + // action + await HierarchyOperations(pouchDb, { mergePrimaryContacts: true }).merge(['district_2'], 'district_1'); + + expectWrittenDocs([ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'district_primary_contact_report' + ]); + + expect(getWrittenDoc('district_2_contact')).to.deep.eq({ + _id: 'district_2_contact', + _deleted: true, + disableUsers: false, + }); + + expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ + _id: 'district_primary_contact_report', + form: 'foo', + type: 'person', + type: 'data_record', + contact: parentsToLineage('district_2_contact', 'district_1'), + fields: { + patient_uuid: 'district_1_contact' + } + }); + }); + + it('--merge-primary-contacts when no primary contact on source', async () => {}); + it('--merge-primary-contacts when no primary contact on destination', async () => {}); }); describe('delete', () => { From 4ee55d4f2aca62117f495ba1d62262f3f794052c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 13:17:28 -0800 Subject: [PATCH 55/66] Scenarios when pc is missing --- src/lib/hierarchy-operations/index.js | 2 +- .../hierarchy-operations.spec.js | 72 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 900f5853c..8706d98a8 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -197,7 +197,7 @@ function updateContacts(options, constraints, moveContext) { return moveContext.descendantsAndSelf .map(descendant => { const toDelete = (moveContext.merge && descendant._id === moveContext.sourceId) || - (moveContext.mergePrimaryContacts && descendant._id === moveContext.sourcePrimaryContactId); + (moveContext.mergePrimaryContacts && descendant._id === moveContext.sourcePrimaryContactId && moveContext.destinationPrimaryContactId); if (toDelete) { const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index fd7183255..db3498947 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -792,7 +792,6 @@ describe('hierarchy-operations', () => { }); it('--merge-primary-contacts results in merge of primary contacts and reports', async () => { - // action await HierarchyOperations(pouchDb, { mergePrimaryContacts: true }).merge(['district_2'], 'district_1'); expectWrittenDocs([ @@ -821,8 +820,75 @@ describe('hierarchy-operations', () => { }); }); - it('--merge-primary-contacts when no primary contact on source', async () => {}); - it('--merge-primary-contacts when no primary contact on destination', async () => {}); + it('--merge-primary-contacts when no primary contact on source', async () => { + await upsert('district_2', { + type: 'district_hospital', + }); + + await HierarchyOperations(pouchDb, { mergePrimaryContacts: true }).merge(['district_2'], 'district_1'); + + expectWrittenDocs([ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'district_primary_contact_report' + ]); + + // not deleted + expect(getWrittenDoc('district_2_contact')).to.deep.eq({ + _id: 'district_2_contact', + type: 'person', + parent: parentsToLineage('district_1'), + }); + + // not reassigned + expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ + _id: 'district_primary_contact_report', + form: 'foo', + type: 'person', + type: 'data_record', + contact: parentsToLineage('district_2_contact', 'district_1'), + fields: { + patient_uuid: 'district_2_contact' + } + }); + }); + + it('--merge-primary-contacts when no primary contact on destination', async () => { + await upsert('district_1', { + type: 'district_hospital', + }); + + await HierarchyOperations(pouchDb, { mergePrimaryContacts: true }).merge(['district_2'], 'district_1'); + + expectWrittenDocs([ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'district_primary_contact_report' + ]); + + // not deleted + expect(getWrittenDoc('district_2_contact')).to.deep.eq({ + _id: 'district_2_contact', + type: 'person', + parent: parentsToLineage('district_1'), + }); + + // not reassigned + expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ + _id: 'district_primary_contact_report', + form: 'foo', + type: 'person', + type: 'data_record', + contact: parentsToLineage('district_2_contact', 'district_1'), + fields: { + patient_uuid: 'district_2_contact' + } + }); + }); }); describe('delete', () => { From 4d27638013443f0699b3659d48fd2a5367058f23 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 13:24:51 -0800 Subject: [PATCH 56/66] Eslint --- src/lib/hierarchy-operations/index.js | 4 ++-- test/lib/hierarchy-operations/hierarchy-operations.spec.js | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 8706d98a8..2384fcd3d 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -97,10 +97,10 @@ function getReportsCreatedAtIds(moveContext) { function reassignReportSubjects(report, moveContext) { let updated = false; for (const subjectId of DataSource.SUBJECT_IDS) { - updated |= reassignSingleReport(report, subjectId, moveContext.sourceId, moveContext.destinationId); + updated = updated || reassignSingleReport(report, subjectId, moveContext.sourceId, moveContext.destinationId); if (moveContext.mergePrimaryContacts && moveContext.sourcePrimaryContactId && moveContext.destinationPrimaryContactId) { - updated |= reassignSingleReport(report, subjectId, moveContext.sourcePrimaryContactId, moveContext.destinationPrimaryContactId); + updated = updated || reassignSingleReport(report, subjectId, moveContext.sourcePrimaryContactId, moveContext.destinationPrimaryContactId); } } diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index db3498947..0e8df6bf9 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -745,7 +745,6 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ _id: 'district_primary_contact_report', form: 'foo', - type: 'person', type: 'data_record', contact: parentsToLineage('district_2_contact', 'district_1'), fields: { @@ -811,7 +810,6 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ _id: 'district_primary_contact_report', form: 'foo', - type: 'person', type: 'data_record', contact: parentsToLineage('district_2_contact', 'district_1'), fields: { @@ -846,7 +844,6 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ _id: 'district_primary_contact_report', form: 'foo', - type: 'person', type: 'data_record', contact: parentsToLineage('district_2_contact', 'district_1'), fields: { @@ -881,7 +878,6 @@ describe('hierarchy-operations', () => { expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({ _id: 'district_primary_contact_report', form: 'foo', - type: 'person', type: 'data_record', contact: parentsToLineage('district_2_contact', 'district_1'), fields: { From 8f1e17ba14ff98d835c715df4cb17015e8fbc543 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 9 Dec 2024 13:41:09 -0800 Subject: [PATCH 57/66] Tests should pass --- test/fn/upload-docs.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 514e88ae5..7af6f5ed5 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -12,7 +12,7 @@ uploadDocs.__set__('userPrompt', userPrompt); let fs, expectedDocs; -const API_VERSION_RESPONSE = { status: 200, body: { deploy_info: { version: '4.10.0'} }}; +const API_VERSION_RESPONSE = { status: 200, body: { version: '4.10.0' }}; describe('upload-docs', function() { beforeEach(() => { From 94db4921edc45b691454c4d058c4e21884d57a4e Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 10 Dec 2024 13:12:33 -0800 Subject: [PATCH 58/66] Undefined number of contacts --- src/lib/hierarchy-operations/delete-hierarchy.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/hierarchy-operations/delete-hierarchy.js b/src/lib/hierarchy-operations/delete-hierarchy.js index 599f14c04..c409749da 100644 --- a/src/lib/hierarchy-operations/delete-hierarchy.js +++ b/src/lib/hierarchy-operations/delete-hierarchy.js @@ -22,8 +22,7 @@ async function deleteHierarchy(db, options, sourceIds) { } const affectedContactCount = descendantsAndSelf.length; - - info(`Staged updates to delete ${prettyPrintDocument(sourceDoc)}. ${affectedContactCount.length} contact(s) and ${affectedReportCount} report(s).`); + info(`Staged updates to delete ${prettyPrintDocument(sourceDoc)}. ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); } } From 42a123ce5210d73d871e7e02015010fa4c903e95 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 11 Dec 2024 13:53:27 -0800 Subject: [PATCH 59/66] Bad merge --- .../lineage-constraints.js | 4 +-- .../hierarchy-operations.spec.js | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index 01b9ef790..e2079504e 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -54,8 +54,8 @@ module.exports = async (db, options) => { Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy */ -const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { - const contactTypeError = getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc); +const getMovingViolations = (contactTypeInfo, sourceDoc, destinationDoc) => { + const contactTypeError = getMovingContactTypeError(contactTypeInfo, sourceDoc, destinationDoc); const circularHierarchyError = findCircularHierarchyErrors(sourceDoc, destinationDoc); return contactTypeError || circularHierarchyError; }; diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 8c3ba84b4..2273ee77b 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -144,6 +144,11 @@ describe('hierarchy-operations', () => { }); }); + it('move root to health_center_1', async () => { + const actual = HierarchyOperations(pouchDb).move(['root'], 'health_center_1'); + await expect(actual).to.eventually.be.rejectedWith(`'root' could not be found`); + }); + it('move health_center_1 to root', async () => { sinon.spy(pouchDb, 'query'); @@ -431,7 +436,7 @@ describe('hierarchy-operations', () => { await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); assert.fail('should throw'); } catch (err) { - expect(err.message).to.include('circular'); + expect(err.message).to.include('itself'); } }); @@ -466,6 +471,26 @@ describe('hierarchy-operations', () => { const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); await expect(actual).to.eventually.rejectedWith('parent of type'); }); + + it('throw if source does not exist', async () => { + const nonExistentId = 'dne_parent_id'; + const actual = HierarchyOperations(pouchDb).move(['health_center_1', nonExistentId], 'district_2'); + await expect(actual).to.eventually.rejectedWith(`Contact with id '${nonExistentId}' could not be found.`); + }); + + it('throw if ancestor does not exist', async () => { + const sourceId = 'health_center_1'; + await upsert(sourceId, { + type: 'health_center', + name: 'no parent', + parent: parentsToLineage('dne'), + }); + + const actual = HierarchyOperations(pouchDb).move([sourceId], 'district_2'); + await expect(actual).to.eventually.rejectedWith( + `(${sourceId}) has parent id(s) 'dne' which could not be found.` + ); + }); describe('batching works as expected', () => { const initialBatchSize = DataSource.BATCH_SIZE; From 02db094f68d7fdfbc92667e550230567e299cc0b Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 11 Dec 2024 13:53:33 -0800 Subject: [PATCH 60/66] Typo in docs --- src/fn/delete-contacts.js | 2 +- src/fn/merge-contacts.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fn/delete-contacts.js b/src/fn/delete-contacts.js index 133d4342a..5d7a1ff78 100644 --- a/src/fn/delete-contacts.js +++ b/src/fn/delete-contacts.js @@ -56,7 +56,7 @@ ${bold('OPTIONS')} A comma delimited list of ids of contacts to be deleted. --disable-users - When flag is present, users at to any deleted place will be permanently disabled. + When flag is present, users at any deleted place will be permanently disabled. --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 76261b5a7..82beac591 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -66,7 +66,7 @@ ${bold('OPTIONS')} A comma delimited list of IDs of contacts which will be deleted. The hierarchy of contacts and reports under it will be moved to be under the destination contact. --disable-users - When flag is present, users at to any deleted place will be permanently disabled. + When flag is present, users at any deleted place will be permanently disabled. --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. From 2bedc8bacfc3b5656e9236a7fae851e35ab9346d Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 11 Dec 2024 14:02:00 -0800 Subject: [PATCH 61/66] Tests passing --- test/fn/upload-docs.spec.js | 15 +++++++-------- .../hierarchy-operations.spec.js | 13 ++++--------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index 7af6f5ed5..755cbc144 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -1,4 +1,5 @@ -const { expect, assert } = require('chai'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); const rewire = require('rewire'); const sinon = require('sinon'); @@ -6,6 +7,9 @@ const apiStub = require('../api-stub'); const environment = require('../../src/lib/environment'); let uploadDocs = rewire('../../src/fn/upload-docs'); const userPrompt = rewire('../../src/lib/user-prompt'); + +const { assert, expect } = chai; +chai.use(chaiAsPromised); let readLine = { keyInYN: () => true }; userPrompt.__set__('readline', readLine); uploadDocs.__set__('userPrompt', userPrompt); @@ -123,13 +127,8 @@ describe('upload-docs', function() { it('should throw if user denies the warning', async () => { userPrompt.__set__('readline', { keyInYN: () => false }); await assertDbEmpty(); - await uploadDocs.execute() - .then(() => { - assert.fail('Expected error to be thrown'); - }) - .catch(err => { - expect(err.message).to.equal('User aborted execution.'); - }); + const actual = uploadDocs.execute(); + await expect(actual).to.eventually.be.rejectedWith('User aborted execution.'); }); it('should not throw if force is set', async () => { diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 2273ee77b..3cade7393 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -13,7 +13,7 @@ chai.use(chaiAsPromised); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const { assert, expect } = chai; +const { expect } = chai; const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations'); const deleteHierarchy = rewire('../../../src/lib/hierarchy-operations/delete-hierarchy'); @@ -431,13 +431,8 @@ describe('hierarchy-operations', () => { it('cannot create circular hierarchy', async () => { // even if the hierarchy rules allow it await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); - - try { - await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('itself'); - } + const actual = HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); + await expect(actual).to.eventually.be.rejectedWith('circular'); }); it('throw if parent does not exist', async () => { @@ -463,7 +458,7 @@ describe('hierarchy-operations', () => { it('throw if setting parent to self', async () => { await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); - await expect(actual).to.eventually.rejectedWith('circular'); + await expect(actual).to.eventually.rejectedWith('itself'); }); it('throw when moving place to unconfigured parent', async () => { From 23817bce8bd701ceeb6803c306586aad3425bd5c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 16 Dec 2024 12:06:31 -0700 Subject: [PATCH 62/66] Bad merge in hierarchy-operations.spec --- .../hierarchy-operations.spec.js | 458 ------------------ 1 file changed, 458 deletions(-) diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index e8d086d43..770d25895 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -971,463 +971,5 @@ describe('hierarchy-operations', () => { const writtenIds = writtenDocs.map(doc => doc._id); expect(writtenIds).to.not.include(['other_report']); }); - - it('move district_1 from root', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - - await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); - - expect(getWrittenDoc('district_1')).to.deep.eq({ - _id: 'district_1', - type: 'district_hospital', - contact: parentsToLineage('district_1_contact', 'district_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('district_1', 'district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), - }); - }); - - it('move district_1 to flexible hierarchy parent', async () => { - await pouchDb.put({ - _id: `county_1`, - type: 'contact', - contact_type: 'county', - }); - - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'district_hospital', parents: ['county'] }, - ]); - - await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('district_1', 'county_1'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), - }); - }); - - it('moves flexible hierarchy contact to flexible hierarchy parent', async () => { - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'subcounty', parents: ['county'] }, - { id: 'focal', parents: ['county', 'subcounty'], person: true } - ]); - - await pouchDb.bulkDocs([ - { _id: `county`, type: 'contact', contact_type: 'county' }, - { _id: `subcounty`, type: 'contact', contact_type: 'subcounty', parent: { _id: 'county' } }, - { _id: `focal`, type: 'contact', contact_type: 'focal', parent: { _id: 'county' } }, - ]); - - await mockReport(pouchDb, { - id: 'report_focal', - creatorId: 'focal', - }); - - await HierarchyOperations(pouchDb).move(['focal'], 'subcounty'); - - expect(getWrittenDoc('focal')).to.deep.eq({ - _id: 'focal', - type: 'contact', - contact_type: 'focal', - parent: parentsToLineage('subcounty', 'county'), - }); - - expect(getWrittenDoc('report_focal')).to.deep.eq({ - _id: 'report_focal', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('focal', 'subcounty', 'county'), - }); - }); - - it('moving primary contact updates parents', async () => { - await mockHierarchy(pouchDb, { - t_district_1: { - t_health_center_1: { - t_clinic_1: { - t_patient_1: {}, - }, - t_clinic_2: { - t_patient_2: {}, - } - }, - }, - }); - - const patient1Lineage = parentsToLineage('t_patient_1', 't_clinic_1', 't_health_center_1', 't_district_1'); - await upsert('t_health_center_1', { - type: 'health_center', - contact: patient1Lineage, - parent: parentsToLineage('t_district_1'), - }); - - await upsert('t_district_1', { - type: 'district_hospital', - contact: patient1Lineage, - parent: parentsToLineage(), - }); - - await HierarchyOperations(pouchDb).move(['t_patient_1'], 't_clinic_2'); - - expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ - _id: 't_health_center_1', - type: 'health_center', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), - parent: parentsToLineage('t_district_1'), - }); - - expect(getWrittenDoc('t_district_1')).to.deep.eq({ - _id: 't_district_1', - type: 'district_hospital', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), - }); - - expectWrittenDocs(['t_patient_1', 't_district_1', 't_health_center_1']); - }); - - // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' - it('district_hospital with empty string parent is not preserved', async () => { - await upsert('district_2', { parent: '', type: 'district_hospital' }); - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - }); - - it('documents should be minified', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); - const patient = { - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - type: 'person', - important: true, - }; - const clinic = { - parent: parentsToLineage('health_center_1', 'district_1'), - type: 'clinic', - important: true, - }; - patient.parent.important = false; - clinic.parent.parent.important = false; - - await upsert('clinic_1', clinic); - await upsert('patient_1', patient); - - await HierarchyOperations(pouchDb).move(['clinic_1'], 'district_2'); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - important: true, - parent: parentsToLineage('district_2'), - }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - important: true, - parent: parentsToLineage('clinic_1', 'district_2'), - }); - }); - - it('cannot create circular hierarchy', async () => { - // even if the hierarchy rules allow it - await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); - const actual = HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); - await expect(actual).to.eventually.be.rejectedWith('circular'); - }); - - it('throw if parent does not exist', async () => { - const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'dne_parent_id'); - await expect(actual).to.eventually.rejectedWith('could not be found'); - }); - - it('throw when altering same lineage', async () => { - const actual = HierarchyOperations(pouchDb).move(['patient_1', 'health_center_1'], 'district_2'); - await expect(actual).to.eventually.rejectedWith('same lineage'); - }); - - it('throw if contact_id is not a contact', async () => { - const actual = HierarchyOperations(pouchDb).move(['report_1'], 'clinic_1'); - await expect(actual).to.eventually.rejectedWith('unknown type'); - }); - - it('throw if moving primary contact of parent', async () => { - const actual = HierarchyOperations(pouchDb).move(['clinic_1_contact'], 'district_1'); - await expect(actual).to.eventually.rejectedWith('primary contact'); - }); - - it('throw if setting parent to self', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); - const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); - await expect(actual).to.eventually.rejectedWith('itself'); - }); - - it('throw when moving place to unconfigured parent', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); - const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); - await expect(actual).to.eventually.rejectedWith('parent of type'); - }); - - it('throw if source does not exist', async () => { - const nonExistentId = 'dne_parent_id'; - const actual = HierarchyOperations(pouchDb).move(['health_center_1', nonExistentId], 'district_2'); - await expect(actual).to.eventually.rejectedWith(`Contact with id '${nonExistentId}' could not be found.`); - }); - - it('throw if ancestor does not exist', async () => { - const sourceId = 'health_center_1'; - await upsert(sourceId, { - type: 'health_center', - name: 'no parent', - parent: parentsToLineage('dne'), - }); - - const actual = HierarchyOperations(pouchDb).move([sourceId], 'district_2'); - await expect(actual).to.eventually.rejectedWith( - `(${sourceId}) has parent id(s) 'dne' which could not be found.` - ); - }); - - describe('batching works as expected', () => { - const initialBatchSize = DataSource.BATCH_SIZE; - beforeEach(async () => { - await mockReport(pouchDb, { - id: 'report_2', - creatorId: 'health_center_1_contact', - }); - - await mockReport(pouchDb, { - id: 'report_3', - creatorId: 'health_center_1_contact', - }); - - await mockReport(pouchDb, { - id: 'report_4', - creatorId: 'health_center_1_contact', - }); - }); - - afterEach(() => { - DataSource.BATCH_SIZE = initialBatchSize; - DataSource.__set__('BATCH_SIZE', initialBatchSize); - }); - - it('move health_center_1 to district_2 in batches of 1', async () => { - DataSource.__set__('BATCH_SIZE', 1); - DataSource.BATCH_SIZE = 1; - sinon.spy(pouchDb, 'query'); - - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(pouchDb.query.callCount).to.deep.equal(6); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 1, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 3, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 4, group_level: undefined }], - ]); - }); - - it('should health_center_1 to district_1 in batches of 2', async () => { - DataSource.__set__('BATCH_SIZE', 2); - DataSource.BATCH_SIZE = 2; - sinon.spy(pouchDb, 'query'); - - await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - parent: parentsToLineage('district_1'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1'), - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - fields: {}, - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(pouchDb.query.callCount).to.deep.equal(4); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 4, group_level: undefined }] - ]); - }); - }); }); }); \ No newline at end of file From 57dc8be5631036100ac4f880f1556565a8c44326 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 16 Dec 2024 12:09:18 -0700 Subject: [PATCH 63/66] Better descriptive variable --- src/lib/hierarchy-operations/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 48143c786..98e8e4a16 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -196,10 +196,12 @@ function replaceLineageInSingleContact(doc, moveContext) { function updateContacts(options, constraints, moveContext) { return moveContext.descendantsAndSelf .map(descendant => { - const toDelete = (moveContext.merge && descendant._id === moveContext.sourceId) || - (moveContext.mergePrimaryContacts && descendant._id === moveContext.sourcePrimaryContactId && moveContext.destinationPrimaryContactId); + const deleteSource = moveContext.merge && descendant._id === moveContext.sourceId; + const deletePrimaryContact = moveContext.mergePrimaryContacts + && descendant._id === moveContext.sourcePrimaryContactId + && moveContext.destinationPrimaryContactId; - if (toDelete) { + if (deleteSource || deletePrimaryContact) { const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant); return { _id: descendant._id, From 78f82e74b3fc2019bc253d2873b25c6ae4468a86 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Mon, 16 Dec 2024 12:14:46 -0700 Subject: [PATCH 64/66] Remove unused options parameter --- src/lib/hierarchy-operations/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 98e8e4a16..edc71a47c 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -26,6 +26,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { descendantsAndSelf, replacementLineage, merge: !!options.merge, + disableUsers: !!options.disableUsers, mergePrimaryContacts: !!options.mergePrimaryContacts, sourcePrimaryContactId: getPrimaryContactId(sourceDoc), destinationPrimaryContactId: getPrimaryContactId(destinationDoc), @@ -35,7 +36,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) { const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; trace(`Considering updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); - const updatedDescendants = updateContacts(options, constraints, moveContext); + const updatedDescendants = updateContacts(moveContext, constraints); const ancestors = await DataSource.getAncestorsOf(db, sourceDoc); trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); @@ -193,7 +194,7 @@ function replaceLineageInSingleContact(doc, moveContext) { } } -function updateContacts(options, constraints, moveContext) { +function updateContacts(moveContext, constraints) { return moveContext.descendantsAndSelf .map(descendant => { const deleteSource = moveContext.merge && descendant._id === moveContext.sourceId; @@ -202,7 +203,7 @@ function updateContacts(options, constraints, moveContext) { && moveContext.destinationPrimaryContactId; if (deleteSource || deletePrimaryContact) { - const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant); + const toDeleteUsers = moveContext.disableUsers && constraints.isPlace(descendant); return { _id: descendant._id, _rev: descendant._rev, From 0a2763426c659c78952639b853d58c57a47fad3c Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 19 Dec 2024 15:20:10 -0700 Subject: [PATCH 65/66] Assert if primary contact is a place --- .../lineage-constraints.js | 53 +++++++++++++------ .../hierarchy-operations.spec.js | 21 +++++++- .../lineage-constraints.spec.js | 22 ++++---- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js index e2079504e..4f3da28af 100644 --- a/src/lib/hierarchy-operations/lineage-constraints.js +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -9,10 +9,8 @@ module.exports = async (db, options) => { return { assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => { - const invalidPrimaryContactDoc = await getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs); - if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the hierarchy for which they are a primary contact.`); - } + await assertOnPrimaryContactRemoval(db, sourceDoc, destinationDoc, descendantDocs); + await assertSourcePrimaryContactType(db, contactTypeInfo, sourceDoc); }, assertNoHierarchyErrors: (sourceDocs, destinationDoc) => { @@ -43,13 +41,16 @@ module.exports = async (db, options) => { }); }, - isPlace: (contact) => { - const contactType = getContactType(contact); - return !contactTypeInfo[contactType]?.person; - }, + isPlace: (contact) => isPlace(contactTypeInfo, contact), }; }; +function isPlace(contactTypeInfo, contact) { + const contactType = getContactType(contact); + const isPerson = contactTypeInfo[contactType]?.person || false; + return !isPerson; +} + /* Enforce the list of allowed parents for each contact type Ensure we are not creating a circular hierarchy @@ -127,11 +128,11 @@ A place's primary contact must be a descendant of that place. 1. Check to see which part of the contact's lineage will be removed 2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed. */ -const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, descendantDocs) => { - const contactsLineageIds = lineageManipulation.pluckIdsFromLineage(contactDoc?.parent); - const parentsLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc); +async function assertOnPrimaryContactRemoval(db, sourceDoc, destinationDoc, descendantDocs) { + const sourceLineageIds = lineageManipulation.pluckIdsFromLineage(sourceDoc?.parent); + const destinationLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc); - const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value)); + const docIdsRemovedFromContactLineage = sourceLineageIds.filter(value => !destinationLineageIds.includes(value)); const docsRemovedFromContactLineage = await db.allDocs({ keys: docIdsRemovedFromContactLineage, include_docs: true, @@ -141,10 +142,32 @@ const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, desce .map(row => row?.doc?.contact?._id) .filter(Boolean); - return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); -}; + const invalidPrimaryContactDoc = descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); + if (invalidPrimaryContactDoc) { + throw Error(`Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the hierarchy for which they are a primary contact.`); + } +} -const getContactType = doc => doc?.type === 'contact' ? doc?.contact_type : doc?.type; +async function assertSourcePrimaryContactType(db, contactTypeInfo, sourceDoc) { + const sourcePrimaryContactId = getPrimaryContactId(sourceDoc); + if (!sourcePrimaryContactId) { + return; + } + + const sourcePrimaryContactDoc = await db.get(sourcePrimaryContactId); + const primaryContactIsPlace = isPlace(contactTypeInfo, sourcePrimaryContactDoc); + if (primaryContactIsPlace) { + throw Error(`Source "${sourceDoc._id}" has primary contact "${sourcePrimaryContactId}" which is of type place`); + } +} + +function getContactType(doc) { + return doc?.type === 'contact' ? doc?.contact_type : doc?.type; +} + +function getPrimaryContactId(doc) { + return typeof doc?.contact === 'string' ? doc.contact : doc?.contact?._id; +} async function fetchContactTypeInfo(db) { try { diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index 770d25895..3d2c7243d 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -152,7 +152,10 @@ describe('hierarchy-operations', () => { it('move health_center_1 to root', async () => { sinon.spy(pouchDb, 'query'); - await updateHierarchyRules([{ id: 'health_center', parents: [] }]); + await updateHierarchyRules([ + { id: 'health_center', parents: [] }, + { id: 'person', parents: [], person: true }, + ]); await HierarchyOperations(pouchDb).move(['health_center_1'], 'root'); @@ -205,7 +208,10 @@ describe('hierarchy-operations', () => { }); it('move district_1 from root', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); + await updateHierarchyRules([ + { id: 'district_hospital', parents: ['district_hospital'] }, + { id: 'person', parents: [], person: true }, + ]); await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); @@ -261,6 +267,7 @@ describe('hierarchy-operations', () => { await updateHierarchyRules([ { id: 'county', parents: [] }, { id: 'district_hospital', parents: ['county'] }, + { id: 'person', parents: [], person: true }, ]); await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); @@ -905,6 +912,16 @@ describe('hierarchy-operations', () => { } }); }); + + it('--merge-primary-contacts errors if primary contact is a place', async () => { + await upsert('district_2', { + type: 'district_hospital', + contact: 'health_center_2', + }); + + const actual = HierarchyOperations(pouchDb, { mergePrimaryContacts: true }).merge(['district_2'], 'district_1'); + await expect(actual).to.eventually.be.rejectedWith('"health_center_2" which is of type place'); + }); }); describe('delete', () => { diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index 857539927..08c2b6c8c 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -100,8 +100,8 @@ describe('lineage constriants', () => { }); }); - describe('getPrimaryContactViolations', () => { - const assertNoHierarchyErrors = lineageConstraints.__get__('getPrimaryContactViolations'); + describe('assertOnPrimaryContactRemoval', () => { + const assertOnPrimaryContactRemoval = lineageConstraints.__get__('assertOnPrimaryContactRemoval'); describe('on memory pouchdb', async () => { let pouchDb, scenarioCount = 0; @@ -131,21 +131,21 @@ describe('lineage constriants', () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_2'); - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); - expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); + const actual = assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, [contactDoc]); + expect(actual).to.eventually.be.rejectedWith(`clinic_1_contact) from the hierarchy`); }); it('cannot move clinic_1_contact to root', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, undefined, [contactDoc]); - expect(doc).to.deep.include({ _id: 'clinic_1_contact' }); + const actual = assertOnPrimaryContactRemoval(pouchDb, contactDoc, undefined, [contactDoc]); + expect(actual).to.eventually.be.rejectedWith(`clinic_1_contact) from the hierarchy`); }); it('can move clinic_1_contact to clinic_1', async () => { const contactDoc = await pouchDb.get('clinic_1_contact'); const parentDoc = await pouchDb.get('clinic_1'); - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); @@ -154,7 +154,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_1'); const descendants = await Promise.all(['health_center_2_contact', 'clinic_2', 'clinic_2_contact', 'patient_2'].map(id => pouchDb.get(id))); - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); + const doc = await assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, descendants); expect(doc).to.be.undefined; }); @@ -167,8 +167,8 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, descendants); - expect(doc).to.deep.include({ _id: 'patient_1' }); + const actual = assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, [contactDoc]); + expect(actual).to.eventually.be.rejectedWith(`patient_1) from the hierarchy`); }); // It is possible that one or more parents will not be found. Since these parents are being removed, do not throw @@ -178,7 +178,7 @@ describe('lineage constriants', () => { contactDoc.parent._id = 'dne'; - const doc = await assertNoHierarchyErrors(pouchDb, contactDoc, parentDoc, [contactDoc]); + const doc = await assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, [contactDoc]); expect(doc).to.be.undefined; }); }); From 3d957c5df40954cb3d86f091876d01d6089a8106 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Thu, 19 Dec 2024 15:28:47 -0700 Subject: [PATCH 66/66] Oops --- test/lib/hierarchy-operations/lineage-constraints.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/hierarchy-operations/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js index 08c2b6c8c..624302be0 100644 --- a/test/lib/hierarchy-operations/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -167,7 +167,7 @@ describe('lineage constriants', () => { const parentDoc = await pouchDb.get('district_2'); const descendants = await Promise.all(['health_center_1_contact', 'clinic_1', 'clinic_1_contact', 'patient_1'].map(id => pouchDb.get(id))); - const actual = assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, [contactDoc]); + const actual = assertOnPrimaryContactRemoval(pouchDb, contactDoc, parentDoc, descendants); expect(actual).to.eventually.be.rejectedWith(`patient_1) from the hierarchy`); });