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(#653): add --merge-primary-contacts flag for merge-contacts action #654

Open
wants to merge 74 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 72 commits
Commits
Show all changes
74 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
9536ff6
Late night wireframe
kennsippell Nov 27, 2024
8b751b6
Passing with automation
kennsippell Nov 28, 2024
ad09272
After testing
kennsippell Nov 29, 2024
de78eb0
Passing eslint
kennsippell Nov 29, 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
8cb2840
Merge branch '373-merge-contacts-options' into 373-upload-docs-delete…
kennsippell Dec 6, 2024
0a9db49
Unneeded comment
kennsippell Dec 6, 2024
956c092
New action delete-contacts
kennsippell Dec 7, 2024
c4aff97
Eslint
kennsippell Dec 7, 2024
adabd15
SonarQubing
kennsippell Dec 7, 2024
6ce9c1a
Oops
kennsippell Dec 7, 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
6cb96d8
Merge branch '373-merge-contacts-options' into 373-upload-docs-delete…
kennsippell Dec 9, 2024
d4dcd45
No function nesting
kennsippell Dec 9, 2024
b323c9f
SonarCube after refactor
kennsippell Dec 9, 2024
094d9b1
Merge branch '373-upload-docs-delete-user' into 650-delete-contacts
kennsippell Dec 9, 2024
5c8f83c
Only disable users at places
kennsippell Dec 9, 2024
9685122
Docs were missing
kennsippell Dec 9, 2024
dcff03e
Move flag onto the functions not upload-docs
kennsippell Dec 9, 2024
cac87f3
Assert if core version is insufficient
kennsippell Dec 9, 2024
e88cc23
Missing semicolon
kennsippell Dec 9, 2024
e3a7039
Revert this
kennsippell Dec 9, 2024
38b6316
First test passing
kennsippell Dec 9, 2024
4ee55d4
Scenarios when pc is missing
kennsippell Dec 9, 2024
4d27638
Eslint
kennsippell Dec 9, 2024
8f1e17b
Tests should pass
kennsippell Dec 9, 2024
eac9742
Merge branch '650-delete-contacts' into 653-merge-primaries
kennsippell Dec 9, 2024
94db492
Undefined number of contacts
kennsippell Dec 10, 2024
bdbead6
Merge branch 'main' into 373-upload-docs-delete-user
kennsippell Dec 11, 2024
8234480
Merge branch '373-upload-docs-delete-user' into 650-delete-contacts
kennsippell Dec 11, 2024
42a123c
Bad merge
kennsippell Dec 11, 2024
02db094
Typo in docs
kennsippell Dec 11, 2024
2bedc8b
Tests passing
kennsippell Dec 11, 2024
0231e60
Merge branch '650-delete-contacts' into 653-merge-primaries
kennsippell Dec 11, 2024
6eb7cf8
Merge branch 'main' into 653-merge-primaries
kennsippell Dec 16, 2024
23817bc
Bad merge in hierarchy-operations.spec
kennsippell Dec 16, 2024
57dc8be
Better descriptive variable
kennsippell Dec 16, 2024
78f82e7
Remove unused options parameter
kennsippell Dec 16, 2024
0a27634
Assert if primary contact is a place
kennsippell Dec 19, 2024
3d957c5
Oops
kennsippell Dec 19, 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
2 changes: 1 addition & 1 deletion src/fn/delete-contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 any deleted place will be permanently disabled.
When flag is present, users at any deleted place will be updated and may be permanently disabled. Supported by CHT Core 4.7 and above.

--docDirectoryPath=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
Expand Down
5 changes: 5 additions & 0 deletions src/fn/merge-contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
};
Expand All @@ -68,6 +70,9 @@ ${bold('OPTIONS')}
--disable-users
When flag is present, users at any deleted place will be updated and may be permanently disabled. Supported by CHT Core 4.7 and above.

--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=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
`);
Expand Down
5 changes: 2 additions & 3 deletions src/lib/hierarchy-operations/delete-hierarchy.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,15 @@ 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).`);
}
}

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);
Expand Down
12 changes: 5 additions & 7 deletions src/lib/hierarchy-operations/hierarchy-data-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -95,6 +92,7 @@ async function getAncestorsOf(db, contactDoc) {
module.exports = {
BATCH_SIZE,
HIERARCHY_ROOT,
SUBJECT_IDS,
getAncestorsOf,
getContactWithDescendants,
getContact,
Expand Down
86 changes: 65 additions & 21 deletions src/lib/hierarchy-operations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,24 @@ 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,
disableUsers: !!options.disableUsers,
mergePrimaryContacts: !!options.mergePrimaryContacts,
sourcePrimaryContactId: getPrimaryContactId(sourceDoc),
destinationPrimaryContactId: getPrimaryContactId(destinationDoc),
};

await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf);

if (options.merge) {
const toDeleteUsers = options.disableUsers && constraints.isPlace(sourceDoc);
JsDocs.deleteDoc(options, sourceDoc, toDeleteUsers);
}

const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`);
const updatedDescendants = replaceLineageInContacts(moveContext);
trace(`Considering updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`);
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)}.`);
Expand All @@ -56,15 +56,19 @@ 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);

let skip = 0;
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);
Expand All @@ -78,24 +82,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 = 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 = updated || reassignSingleReport(report, subjectId, moveContext.sourcePrimaryContactId, moveContext.destinationPrimaryContactId);
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
}
}

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) {
Expand Down Expand Up @@ -167,9 +194,26 @@ function replaceLineageInSingleContact(doc, moveContext) {
}
}

function replaceLineageInContacts(moveContext) {
function updateContacts(moveContext, constraints) {
return moveContext.descendantsAndSelf
.map(descendant => replaceLineageInSingleContact(descendant, moveContext))
.map(descendant => {
const deleteSource = moveContext.merge && descendant._id === moveContext.sourceId;
const deletePrimaryContact = moveContext.mergePrimaryContacts
&& descendant._id === moveContext.sourcePrimaryContactId
&& moveContext.destinationPrimaryContactId;

if (deleteSource || deletePrimaryContact) {
const toDeleteUsers = moveContext.disableUsers && constraints.isPlace(descendant);
return {
_id: descendant._id,
_rev: descendant._rev,
kennsippell marked this conversation as resolved.
Show resolved Hide resolved
_deleted: true,
cht_disable_linked_users: !!toDeleteUsers,
};
}

return replaceLineageInSingleContact(descendant, moveContext);
})
.filter(Boolean);
}

Expand Down
1 change: 1 addition & 0 deletions test/fn/merge-contacts.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('merge-contacts', () => {
sourceIds: ['food', 'is', 'tasty'],
destinationId: 'bar',
disableUsers: false,
mergePrimaryContacts: false,
force: true,
docDirectoryPath: '/',
});
Expand Down
1 change: 1 addition & 0 deletions test/fn/upload-docs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe('upload-docs', function() {

it('should throw if user denies the warning', async () => {
userPrompt.__set__('readline', { keyInYN: () => false });
await assertDbEmpty();
const actual = uploadDocs.execute();
await expect(actual).to.eventually.be.rejectedWith('User aborted execution.');
});
Expand Down
116 changes: 115 additions & 1 deletion test/lib/hierarchy-operations/hierarchy-operations.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,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, {
Expand Down Expand Up @@ -692,7 +700,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({
Expand Down Expand Up @@ -753,6 +761,16 @@ describe('hierarchy-operations', () => {
patient_uuid: 'district_1'
}
});

expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({
_id: 'district_primary_contact_report',
form: 'foo',
type: 'data_record',
contact: parentsToLineage('district_2_contact', 'district_1'),
fields: {
patient_uuid: 'district_2_contact'
}
});
});

it('merge two patients', async () => {
Expand Down Expand Up @@ -791,6 +809,102 @@ describe('hierarchy-operations', () => {
}
});
});

it('--merge-primary-contacts results in merge of primary contacts and reports', async () => {
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,
cht_disable_linked_users: false,
});

expect(getWrittenDoc('district_primary_contact_report')).to.deep.eq({
_id: 'district_primary_contact_report',
form: 'foo',
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 () => {
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: '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: 'data_record',
contact: parentsToLineage('district_2_contact', 'district_1'),
fields: {
patient_uuid: 'district_2_contact'
}
});
});
});

describe('delete', () => {
Expand Down