Skip to content

Commit

Permalink
feat(#650): delete-contacts action (#652)
Browse files Browse the repository at this point in the history
This adds new action cht delete-contacts which recursively deletes all contacts and reports under a place.

npx cht delete-contacts upload-docs --local -- --contacts=uuid1
  • Loading branch information
kennsippell authored Dec 16, 2024
1 parent 052aaa3 commit 129f38f
Show file tree
Hide file tree
Showing 7 changed files with 717 additions and 526 deletions.
64 changes: 64 additions & 0 deletions src/fn/delete-contacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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,
disableUsers: args.disableUsers,
};
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.contacts || args.contact || '')
.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,
disableUsers: !!args['disable-users'],
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 -- --contacts=<id1>,<id2>
${bold('OPTIONS')}
--contacts=<id1>,<id2> (or --contact=<id1>,<id2>)
A comma delimited list of ids of contacts to be deleted.
--disable-users
When flag is present, users at any deleted place will be permanently disabled.
--docDirectoryPath=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
`);
};
46 changes: 46 additions & 0 deletions src/lib/hierarchy-operations/delete-hierarchy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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) {
JsDocs.prepareFolder(options);

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)}`);
const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId);

let affectedReportCount = 0;
for (const descendant of descendantsAndSelf) {
const toDeleteUsers = options.disableUsers && constraints.isPlace(descendant);
JsDocs.deleteDoc(options, descendant, toDeleteUsers);
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;
}

module.exports = deleteHierarchy;
18 changes: 7 additions & 11 deletions src/lib/hierarchy-operations/index.js
Original file line number Diff line number Diff line change
@@ -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');

async function moveHierarchy(db, options, sourceIds, destinationId) {
JsDocs.prepareFolder(options);
trace(`Fetching contact details: ${destinationId}`);
Expand All @@ -30,12 +30,8 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf);

if (options.merge) {
JsDocs.writeDoc(options, {
_id: sourceDoc._id,
_rev: sourceDoc._rev,
_deleted: true,
cht_disable_linked_users: !!options.disableUsers,
});
const toDeleteUsers = options.disableUsers && constraints.isPlace(sourceDoc);
JsDocs.deleteDoc(options, sourceDoc, toDeleteUsers);
}

const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
Expand Down Expand Up @@ -177,11 +173,11 @@ function replaceLineageInContacts(moveContext) {
.filter(Boolean);
}

module.exports = (db, options) => {
module.exports = (db, options = {}) => {
return {
HIERARCHY_ROOT: DataSource.HIERARCHY_ROOT,
move: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: false }, sourceIds, destinationId),
merge: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: true }, sourceIds, destinationId),
delete: async (sourceIds) => deleteHierarchy(db, options, sourceIds),
};
};

10 changes: 10 additions & 0 deletions src/lib/hierarchy-operations/jsdocFolder.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ function deleteAfterConfirmation(docDirectoryPath) {
fs.deleteFilesInFolder(docDirectoryPath);
}

function deleteDoc(options, doc, disableUsers) {
writeDoc(options, {
_id: doc._id,
_rev: doc._rev,
_deleted: true,
cht_disable_linked_users: !!disableUsers,
});
}

module.exports = {
deleteDoc,
prepareFolder,
writeDoc,
};
Expand Down
39 changes: 22 additions & 17 deletions src/lib/hierarchy-operations/lineage-constraints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -20,7 +20,7 @@ module.exports = async (db, options) => {
const commonViolations = getCommonViolations(sourceDoc, destinationDoc);
const specificViolation = options.merge ?
getMergeViolations(sourceDoc, destinationDoc)
: getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc);
: getMovingViolations(contactTypeInfo, sourceDoc, destinationDoc);

const hierarchyError = commonViolations || specificViolation;
if (hierarchyError) {
Expand All @@ -41,30 +41,35 @@ 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;
},
};
};

/*
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;
};

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}'`;
}
}
Expand Down Expand Up @@ -139,9 +144,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');

Expand All @@ -150,7 +155,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) {
Expand All @@ -161,10 +166,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 },
};
}

1 change: 0 additions & 1 deletion test/fn/upload-docs.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');

const rewire = require('rewire');
const sinon = require('sinon');

Expand Down
Loading

0 comments on commit 129f38f

Please sign in to comment.