Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#373): merge-contacts action #647

Merged
merged 44 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d16df6d
Testing this common library going to get weird
kennsippell Nov 7, 2024
3e1827e
Move-Contacts tests passing again
kennsippell Nov 7, 2024
d914e64
First test passing for merge
kennsippell Nov 12, 2024
090895c
Negative cases
kennsippell Nov 12, 2024
3e6168c
Fix move-contacts tests again
kennsippell Nov 12, 2024
2449dd4
Some renaming
kennsippell Nov 21, 2024
b5f8c3b
Refactor to use options
kennsippell Nov 21, 2024
1273fb6
Move folder structure
kennsippell Nov 21, 2024
25ad230
Lineage Constraints
kennsippell Nov 21, 2024
5ad9d85
Rename to Hierarchy Operations
kennsippell Nov 22, 2024
7ea3393
replaceRelevantLineage
kennsippell Nov 22, 2024
78f2c01
Refacatoring for lineage-manipulation
kennsippell Nov 23, 2024
d677b48
Tests for fn folder
kennsippell Nov 23, 2024
2442fcc
Pass eslint
kennsippell Nov 23, 2024
a0a0c84
Backend interface change
kennsippell Nov 23, 2024
f73f9c6
Fix failing test in mock-hierarchies
kennsippell Nov 23, 2024
8e35f2d
SonarCube
kennsippell Nov 23, 2024
17c4c04
SonarQube - Is his really better code?
kennsippell Nov 23, 2024
7af035c
SonarQube - Fix?
kennsippell Nov 23, 2024
687a6a2
SonarQube
kennsippell Nov 23, 2024
49c6d51
Oops
kennsippell Nov 23, 2024
6d0cc3e
Reduced nesting via curried function
kennsippell Dec 6, 2024
e561431
4 feedbacks
kennsippell Dec 6, 2024
92ae094
Remove getHierarchyErrors public interface
kennsippell Dec 6, 2024
d68a294
Lots of lineage-constraints feedback
kennsippell Dec 6, 2024
c964aa7
Remove lineageAttribute
kennsippell Dec 6, 2024
88ea9fd
Still code reviewing
kennsippell Dec 6, 2024
296088a
Eslint
kennsippell Dec 6, 2024
42c6789
One more
kennsippell Dec 6, 2024
8f2bbd6
Why 5? wtf
kennsippell Dec 6, 2024
4ecf723
Phrasing
kennsippell Dec 6, 2024
af9a9ac
lineage-manipulation refactor
kennsippell Dec 9, 2024
546f9cb
Docs
kennsippell Dec 9, 2024
fe27a5a
Oh that is why
kennsippell Dec 9, 2024
28be7fb
Remove function nesting
kennsippell Dec 9, 2024
99745c6
Last code review feedback
kennsippell Dec 9, 2024
51f76da
Update src/lib/hierarchy-operations/lineage-constraints.js
kennsippell Dec 11, 2024
171c216
Update src/lib/hierarchy-operations/index.js
kennsippell Dec 11, 2024
b96166e
Review feedback
kennsippell Dec 11, 2024
b8332f6
Merge branch '373-merge-contacts-options' of https://github.com/medic…
kennsippell Dec 11, 2024
e147546
Update test/lib/hierarchy-operations/hierarchy-operations.spec.js
kennsippell Dec 11, 2024
955a108
Update test/lib/hierarchy-operations/jsdocs.spec.js
kennsippell Dec 11, 2024
d3da109
Update test/lib/hierarchy-operations/lineage-constraints.spec.js
kennsippell Dec 11, 2024
495b9e0
Are we done?
kennsippell Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ upload-docs.*.log.json
/.vscode/
/.idea/
/.settings/
/json_docs/
*.swp
coverage
.nyc_output
Expand Down
69 changes: 69 additions & 0 deletions src/fn/merge-contacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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).merge(args.sourceIds, args.destinationId);
}
};

// Parses extraArgs and asserts if required parameters are not present
const parseExtraArgs = (projectDir, extraArgs = []) => {
const args = minimist(extraArgs, { boolean: true });

const sourceIds = (args.sources || args.source || '')
.split(',')
.filter(Boolean);

if (!args.destination) {
usage();
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('--sources')}. These contacts will be merged into the contact specified by ${bold('--destination')}`);
}

return {
destinationId: args.destination,
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 merge-contacts action')}
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 ${bold('source contact(s)')} are deleted and no data from this document is merged or preserved.

${bold('USAGE')}
cht --local merge-contacts -- --destination=<destination_id> --sources=<source_id1>,<source_id2>

${bold('OPTIONS')}
--destination=<destination_id>
Specifies the ID of the contact that should receive the moving contacts and reports.

--sources=<source_id1>,<source_id2>
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=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
`);
};
249 changes: 13 additions & 236 deletions src/fn/move-contacts.js
Original file line number Diff line number Diff line change
@@ -1,105 +1,34 @@
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 { info } = require('../lib/log');

const HIERARCHY_ROOT = 'root';
const BATCH_SIZE = 10000;
const HierarchyOperations = require('../lib/hierarchy-operations');

module.exports = {
requiresInstance: true,
execute: () => {
const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
const db = pouch();
prepareDocumentDirectory(args);
return updateLineagesAndStage(args, db);
const options = {
docDirectoryPath: args.docDirectoryPath,
force: args.force,
};
return HierarchyOperations(db, options).move(args.sourceIds, args.destinationId);
jkuester marked this conversation as resolved.
Show resolved Hide resolved
}
};

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);

const constraints = await lineageConstraints(db, parentDoc);
const contactDocs = await 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);

// 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, 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);

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).`);
}

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 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
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');
}
Expand All @@ -110,28 +39,15 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
}

return {
parentId: args.parent,
contactIds,
destinationId: args.parent,
sourceIds,
docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
force: !!args.force,
};
};

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 bold = text => `\x1b[1m${text}\x1b[0m`;
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.
Expand All @@ -144,148 +60,9 @@ ${bold('OPTIONS')}
A comma delimited list of ids of contacts to be moved.

--parent=<parent_id>
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 '${HierarchyOperations.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent).

--docDirectoryPath=<path to stage docs>
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 + BATCH_SIZE} report docs`);
reportDocsBatch = await fetch.reportsCreatedBy(db, contactIds, skip);

const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId);
minifyLineageAndWriteToDisk(updatedReports, writeOptions);

skip += reportDocsBatch.length;
} while (reportDocsBatch.length >= BATCH_SIZE);

return skip;
};

const minifyLineageAndWriteToDisk = (docs, parsedArgs) => {
docs.forEach(doc => {
lineageManipulation.minifyLineagesInDoc(doc);
writeDocumentToDisk(parsedArgs, doc);
});
};

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);
}
return agg;
}, []);

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);
if (parentWasUpdated || contactWasUpdated) {
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;
}, []);
Loading