cht-conf 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [6.1.0](https://github.com/medic/cht-conf/compare/v6.0.2...v6.1.0) (2026-04-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * **#653:** add --merge-primary-contacts flag for merge-contacts action ([#654](https://github.com/medic/cht-conf/issues/654)) ([def533d](https://github.com/medic/cht-conf/commit/def533dbca20abca8303aea7f314921c6a1c166b)), closes [#653](https://github.com/medic/cht-conf/issues/653)
7
+
1
8
  ## [6.0.2](https://github.com/medic/cht-conf/compare/v6.0.1...v6.0.2) (2026-01-16)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "6.0.2",
3
+ "version": "6.1.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -57,7 +57,7 @@ ${bold('OPTIONS')}
57
57
  A comma delimited list of ids of contacts to be deleted.
58
58
 
59
59
  --disable-users
60
- When flag is present, users at any deleted place will be permanently disabled.
60
+ 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.
61
61
 
62
62
  --docDirectoryPath=<path to stage docs>
63
63
  Specifies the folder used to store the documents representing the changes in hierarchy.
@@ -15,6 +15,7 @@ module.exports = {
15
15
  const options = {
16
16
  disableUsers: args.disableUsers,
17
17
  docDirectoryPath: args.docDirectoryPath,
18
+ mergePrimaryContacts: args.mergePrimaryContacts,
18
19
  force: args.force,
19
20
  };
20
21
  return HierarchyOperations(db, options).merge(args.sourceIds, args.destinationId);
@@ -50,6 +51,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
50
51
  sourceIds,
51
52
  disableUsers: !!args['disable-users'],
52
53
  docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
54
+ mergePrimaryContacts: !!args['merge-primary-contacts'],
53
55
  force: !!args.force,
54
56
  };
55
57
  };
@@ -75,6 +77,9 @@ ${bold('OPTIONS')}
75
77
  --disable-users
76
78
  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.
77
79
 
80
+ --merge-primary-contacts
81
+ When flag is present, the primary contacts for all the top-level places will also be merged into a single resulting contact.
82
+
78
83
  --docDirectoryPath=<path to stage docs>
79
84
  Specifies the folder used to store the documents representing the changes in hierarchy.
80
85
  `);
@@ -34,7 +34,7 @@ async function deleteReportsForContact(db, options, contact) {
34
34
  let skip = 0;
35
35
  let reportBatch;
36
36
  do {
37
- reportBatch = await DataSource.getReportsForContacts(db, [], contact._id, skip);
37
+ reportBatch = await DataSource.getReportsForContacts(db, [], [contact._id], skip);
38
38
 
39
39
  for (const report of reportBatch) {
40
40
  JsDocs.deleteDoc(options, report);
@@ -6,6 +6,7 @@ const api = require('../api');
6
6
  const HIERARCHY_ROOT = 'root';
7
7
  const BATCH_SIZE = 10000;
8
8
  const NOUVEAU_MIN_VERSION = '5.0.0';
9
+ const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid'];
9
10
 
10
11
  /*
11
12
  Fetches all of the documents associated with the "contactIds" and confirms they exist.
@@ -84,17 +85,17 @@ const fetchReportsByCreator = async (db, createdByIds, skip) => {
84
85
  return await getFromDbView(db, 'medic-client/reports_by_freetext', createdByKeys, skip);
85
86
  };
86
87
 
87
- const fetchReportsBySubject = async (db, createdAtId, skip) => {
88
- if (!createdAtId) {
88
+ const fetchReportsBySubject = async (db, createdAtIds, skip) => {
89
+ if (!createdAtIds || createdAtIds.length <= 0) {
89
90
  return [];
90
91
  }
91
- return await getFromDbView(db, 'medic-client/reports_by_subject', [createdAtId], skip);
92
+ return await getFromDbView(db, 'medic-client/reports_by_subject', createdAtIds, skip);
92
93
  };
93
94
 
94
- const getReportsForContacts = async (db, createdByIds, createdAtId, skip) => {
95
+ const getReportsForContacts = async (db, createdByIds, createdAtIds, skip) => {
95
96
  const [creatorReports, subjectReports] = await Promise.all([
96
97
  fetchReportsByCreator(db, createdByIds, skip),
97
- fetchReportsBySubject(db, createdAtId, skip)
98
+ fetchReportsBySubject(db, createdAtIds, skip)
98
99
  ]);
99
100
 
100
101
  const allRows = [...creatorReports, ...subjectReports];
@@ -124,6 +125,7 @@ async function getAncestorsOf(db, contactDoc) {
124
125
  module.exports = {
125
126
  BATCH_SIZE,
126
127
  HIERARCHY_ROOT,
128
+ SUBJECT_IDS,
127
129
  getAncestorsOf,
128
130
  getContactWithDescendants,
129
131
  getContact,
@@ -19,27 +19,27 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
19
19
  for (const sourceId of sourceIds) {
20
20
  const sourceDoc = sourceDocs[sourceId];
21
21
  const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId);
22
+
22
23
  const moveContext = {
23
24
  sourceId,
24
25
  destinationId,
25
26
  descendantsAndSelf,
26
27
  replacementLineage,
27
28
  merge: !!options.merge,
29
+ disableUsers: !!options.disableUsers,
30
+ mergePrimaryContacts: !!options.mergePrimaryContacts,
31
+ sourcePrimaryContactId: getPrimaryContactId(sourceDoc),
32
+ destinationPrimaryContactId: getPrimaryContactId(destinationDoc),
28
33
  };
29
34
 
30
35
  await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf);
31
36
 
32
- if (options.merge) {
33
- const toDeleteUsers = options.disableUsers && constraints.isPlace(sourceDoc);
34
- JsDocs.deleteDoc(options, sourceDoc, toDeleteUsers);
35
- }
36
-
37
37
  const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
38
38
  trace(
39
39
  `Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact `
40
40
  + `${prettyPrintDocument(sourceDoc)}.`
41
41
  );
42
- const updatedDescendants = replaceLineageInContacts(moveContext);
42
+ const updatedDescendants = updateContacts(moveContext, constraints);
43
43
 
44
44
  const ancestors = await DataSource.getAncestorsOf(db, sourceDoc);
45
45
  trace(
@@ -68,6 +68,10 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
68
68
  );
69
69
  }
70
70
 
71
+ function getPrimaryContactId(doc) {
72
+ return typeof doc?.contact === 'string' ? doc.contact : doc?.contact?._id;
73
+ }
74
+
71
75
  async function updateReports(db, options, moveContext) {
72
76
  const descendantIds = moveContext.descendantsAndSelf.map(contact => contact._id);
73
77
 
@@ -75,8 +79,8 @@ async function updateReports(db, options, moveContext) {
75
79
  let reportDocsBatch;
76
80
  do {
77
81
  info(`Processing ${skip} to ${skip + DataSource.BATCH_SIZE} report docs`);
78
- const createdAtId = options.merge && moveContext.sourceId;
79
- reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip);
82
+ const createdAtIds = getReportsCreatedAtIds(moveContext);
83
+ reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtIds, skip);
80
84
 
81
85
  const lineageUpdates = replaceLineageOfReportCreator(reportDocsBatch, moveContext);
82
86
  const reassignUpdates = reassignReports(reportDocsBatch, moveContext);
@@ -90,24 +94,53 @@ async function updateReports(db, options, moveContext) {
90
94
  return skip;
91
95
  }
92
96
 
93
- function reassignReportSubjects(report, { sourceId, destinationId }) {
94
- const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid'];
95
- let updated = false;
96
- for (const subjectId of SUBJECT_IDS) {
97
- if (report[subjectId] === sourceId) {
98
- report[subjectId] = destinationId;
99
- updated = true;
100
- }
97
+ function getReportsCreatedAtIds(moveContext) {
98
+ const result = [];
99
+ if (moveContext.merge) {
100
+ result.push(moveContext.sourceId);
101
+ }
102
+
103
+ if (moveContext.mergePrimaryContacts && moveContext.sourcePrimaryContactId) {
104
+ result.push(moveContext.sourcePrimaryContactId);
105
+ }
101
106
 
102
- if (report.fields[subjectId] === sourceId) {
103
- report.fields[subjectId] = destinationId;
104
- updated = true;
107
+ return result;
108
+ }
109
+
110
+ function reassignReportSubjects(report, moveContext) {
111
+ let updated = false;
112
+ for (const subjectId of DataSource.SUBJECT_IDS) {
113
+ updated = updated || reassignSingleReport(report, subjectId, moveContext.sourceId, moveContext.destinationId);
114
+
115
+ if (
116
+ moveContext.mergePrimaryContacts &&
117
+ moveContext.sourcePrimaryContactId &&
118
+ moveContext.destinationPrimaryContactId
119
+ ) {
120
+ updated = updated || reassignSingleReport(
121
+ report, subjectId, moveContext.sourcePrimaryContactId, moveContext.destinationPrimaryContactId
122
+ );
105
123
  }
106
124
  }
107
125
 
108
126
  return updated;
109
127
  }
110
128
 
129
+ function reassignSingleReport(report, subjectId, matchId, resultingId) {
130
+ let result = false;
131
+ if (report[subjectId] === matchId) {
132
+ report[subjectId] = resultingId;
133
+ result = true;
134
+ }
135
+
136
+ if (report.fields[subjectId] === matchId) {
137
+ report.fields[subjectId] = resultingId;
138
+ result = true;
139
+ }
140
+
141
+ return result;
142
+ }
143
+
111
144
  function reassignReports(reports, moveContext) {
112
145
  const updated = new Set();
113
146
  if (!moveContext.merge) {
@@ -179,9 +212,26 @@ function replaceLineageInSingleContact(doc, moveContext) {
179
212
  }
180
213
  }
181
214
 
182
- function replaceLineageInContacts(moveContext) {
215
+ function updateContacts(moveContext, constraints) {
183
216
  return moveContext.descendantsAndSelf
184
- .map(descendant => replaceLineageInSingleContact(descendant, moveContext))
217
+ .map(descendant => {
218
+ const deleteSource = moveContext.merge && descendant._id === moveContext.sourceId;
219
+ const deletePrimaryContact = moveContext.mergePrimaryContacts
220
+ && descendant._id === moveContext.sourcePrimaryContactId
221
+ && moveContext.destinationPrimaryContactId;
222
+
223
+ if (deleteSource || deletePrimaryContact) {
224
+ const toDeleteUsers = moveContext.disableUsers && constraints.isPlace(descendant);
225
+ return {
226
+ _id: descendant._id,
227
+ _rev: descendant._rev,
228
+ _deleted: true,
229
+ cht_disable_linked_users: !!toDeleteUsers,
230
+ };
231
+ }
232
+
233
+ return replaceLineageInSingleContact(descendant, moveContext);
234
+ })
185
235
  .filter(Boolean);
186
236
  }
187
237
 
@@ -9,13 +9,8 @@ module.exports = async (db, options) => {
9
9
 
10
10
  return {
11
11
  assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => {
12
- const invalidPrimaryContactDoc = await getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs);
13
- if (invalidPrimaryContactDoc) {
14
- throw Error(
15
- `Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the `
16
- + 'hierarchy for which they are a primary contact.'
17
- );
18
- }
12
+ await assertOnPrimaryContactRemoval(db, sourceDoc, destinationDoc, descendantDocs);
13
+ await assertSourcePrimaryContactType(db, contactTypeInfo, sourceDoc);
19
14
  },
20
15
  assertNoHierarchyErrors: (sourceDocs, destinationDoc) => {
21
16
 
@@ -30,7 +25,7 @@ module.exports = async (db, options) => {
30
25
  throw Error(`Hierarchy Constraints: ${hierarchyError}`);
31
26
  }
32
27
  });
33
-
28
+
34
29
  /*
35
30
  * It is nice that the tool can move lists of contacts as one operation, but strange things happen when two
36
31
  * contactIds are in the same lineage. For example, moving a district_hospital and moving a contact under that
@@ -47,13 +42,16 @@ module.exports = async (db, options) => {
47
42
  });
48
43
  },
49
44
 
50
- isPlace: (contact) => {
51
- const contactType = getContactType(contact);
52
- return !contactTypeInfo[contactType]?.person;
53
- },
45
+ isPlace: (contact) => isPlace(contactTypeInfo, contact),
54
46
  };
55
47
  };
56
48
 
49
+ function isPlace(contactTypeInfo, contact) {
50
+ const contactType = getContactType(contact);
51
+ const isPerson = contactTypeInfo[contactType]?.person || false;
52
+ return !isPerson;
53
+ }
54
+
57
55
  /*
58
56
  Enforce the list of allowed parents for each contact type
59
57
  Ensure we are not creating a circular hierarchy
@@ -133,24 +131,50 @@ A place's primary contact must be a descendant of that place.
133
131
  1. Check to see which part of the contact's lineage will be removed
134
132
  2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed.
135
133
  */
136
- const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, descendantDocs) => {
137
- const contactsLineageIds = lineageManipulation.pluckIdsFromLineage(contactDoc?.parent);
138
- const parentsLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc);
134
+ async function assertOnPrimaryContactRemoval(db, sourceDoc, destinationDoc, descendantDocs) {
135
+ const sourceLineageIds = lineageManipulation.pluckIdsFromLineage(sourceDoc?.parent);
136
+ const destinationLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc);
139
137
 
140
- const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value));
138
+ const docIdsRemovedFromContactLineage = sourceLineageIds.filter(value => !destinationLineageIds.includes(value));
141
139
  const docsRemovedFromContactLineage = await db.allDocs({
142
140
  keys: docIdsRemovedFromContactLineage,
143
141
  include_docs: true,
144
142
  });
145
143
 
146
- const primaryContactIds = docsRemovedFromContactLineage.rows
144
+ const primaryContactIds = new Set(docsRemovedFromContactLineage.rows
147
145
  .map(row => row?.doc?.contact?._id)
148
- .filter(Boolean);
149
-
150
- return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId));
151
- };
146
+ .filter(Boolean));
147
+
148
+ const invalidPrimaryContactDoc = descendantDocs.find(
149
+ descendant => primaryContactIds.has(descendant._id)
150
+ );
151
+ if (invalidPrimaryContactDoc) {
152
+ throw new Error('Cannot remove contact '+
153
+ `'${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) ` +
154
+ 'from the hierarchy for which they are a primary contact.');
155
+ }
156
+ }
157
+
158
+ async function assertSourcePrimaryContactType(db, contactTypeInfo, sourceDoc) {
159
+ const sourcePrimaryContactId = getPrimaryContactId(sourceDoc);
160
+ if (!sourcePrimaryContactId) {
161
+ return;
162
+ }
163
+
164
+ const sourcePrimaryContactDoc = await db.get(sourcePrimaryContactId);
165
+ const primaryContactIsPlace = isPlace(contactTypeInfo, sourcePrimaryContactDoc);
166
+ if (primaryContactIsPlace) {
167
+ throw new Error(`Source "${sourceDoc._id}" has primary contact "${sourcePrimaryContactId}" which is of type place`);
168
+ }
169
+ }
170
+
171
+ function getContactType(doc) {
172
+ return doc?.type === 'contact' ? doc?.contact_type : doc?.type;
173
+ }
152
174
 
153
- const getContactType = doc => doc?.type === 'contact' ? doc?.contact_type : doc?.type;
175
+ function getPrimaryContactId(doc) {
176
+ return typeof doc?.contact === 'string' ? doc.contact : doc?.contact?._id;
177
+ }
154
178
 
155
179
  async function fetchContactTypeInfo(db) {
156
180
  try {