cht-conf 4.2.0 → 4.3.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
+ # [4.3.0](https://github.com/medic/cht-conf/compare/v4.2.0...v4.3.0) (2024-12-16)
2
+
3
+
4
+ ### Features
5
+
6
+ * **#648:** add --disable-users command for merge-contacts action ([#649](https://github.com/medic/cht-conf/issues/649)) ([c78d3fd](https://github.com/medic/cht-conf/commit/c78d3fd1ed1588cfefdf21384840743f5faea62d)), closes [#648](https://github.com/medic/cht-conf/issues/648)
7
+
1
8
  # [4.2.0](https://github.com/medic/cht-conf/compare/v4.1.3...v4.2.0) (2024-12-11)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -13,6 +13,7 @@ module.exports = {
13
13
  const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
14
14
  const db = pouch();
15
15
  const options = {
16
+ disableUsers: args.disableUsers,
16
17
  docDirectoryPath: args.docDirectoryPath,
17
18
  force: args.force,
18
19
  };
@@ -41,6 +42,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
41
42
  return {
42
43
  destinationId: args.destination,
43
44
  sourceIds,
45
+ disableUsers: !!args['disable-users'],
44
46
  docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
45
47
  force: !!args.force,
46
48
  };
@@ -63,6 +65,9 @@ ${bold('OPTIONS')}
63
65
  --sources=<source_id1>,<source_id2>
64
66
  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.
65
67
 
68
+ --disable-users
69
+ 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.
70
+
66
71
  --docDirectoryPath=<path to stage docs>
67
72
  Specifies the folder used to store the documents representing the changes in hierarchy.
68
73
  `);
@@ -1,19 +1,22 @@
1
1
  const path = require('path');
2
2
  const minimist = require('minimist');
3
- const userPrompt = require('../lib/user-prompt');
3
+ const semver = require('semver');
4
4
 
5
+ const api = require('../lib/api');
5
6
  const environment = require('../lib/environment');
6
7
  const fs = require('../lib/sync-fs');
8
+ const { getValidApiVersion } = require('../lib/get-api-version');
7
9
  const log = require('../lib/log');
8
10
  const pouch = require('../lib/db');
9
11
  const progressBar = require('../lib/progress-bar');
12
+ const userPrompt = require('../lib/user-prompt');
10
13
 
11
14
  const { info, trace, warn } = log;
12
15
 
13
16
  const FILE_EXTENSION = '.doc.json';
14
17
  const INITIAL_BATCH_SIZE = 100;
15
18
 
16
- const execute = async () => {
19
+ async function execute() {
17
20
  const args = minimist(environment.extraArgs || [], { boolean: true });
18
21
 
19
22
  const docDir = path.resolve(environment.pathToProject, args.docDirectoryPath || 'json_docs');
@@ -22,22 +25,23 @@ const execute = async () => {
22
25
  return Promise.resolve();
23
26
  }
24
27
 
25
- const filesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION));
26
- const docIdErrors = getErrorsWhereDocIdDiffersFromFilename(filesToUpload);
27
- if (docIdErrors.length > 0) {
28
- throw new Error(`upload-docs: ${docIdErrors.join('\n')}`);
29
- }
30
-
31
- const totalCount = filesToUpload.length;
28
+ const filenamesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION));
29
+ const totalCount = filenamesToUpload.length;
32
30
  if (totalCount === 0) {
33
31
  return; // nothing to upload
34
32
  }
35
33
 
36
- warn(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`);
37
- if (!userPrompt.keyInYN()) {
38
- throw new Error('User aborted execution.');
34
+ const analysis = analyseFiles(filenamesToUpload);
35
+ const errors = analysis.map(result => result.error).filter(Boolean);
36
+ if (errors.length > 0) {
37
+ throw new Error(`upload-docs: ${errors.join('\n')}`);
39
38
  }
40
39
 
40
+ userPrompt.warnPromptAbort(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`);
41
+
42
+ const deletedDocIds = analysis.map(result => result.delete).filter(Boolean);
43
+ await handleUsersAtDeletedFacilities(deletedDocIds);
44
+
41
45
  const results = { ok:[], failed:{} };
42
46
  const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null;
43
47
  const processNextBatch = async (docFiles, batchSize) => {
@@ -93,20 +97,102 @@ const execute = async () => {
93
97
  }
94
98
  };
95
99
 
96
- return processNextBatch(filesToUpload, INITIAL_BATCH_SIZE);
97
- };
100
+ return processNextBatch(filenamesToUpload, INITIAL_BATCH_SIZE);
101
+ }
98
102
 
99
- const getErrorsWhereDocIdDiffersFromFilename = filePaths =>
100
- filePaths
103
+ function analyseFiles(filePaths) {
104
+ return filePaths
101
105
  .map(filePath => {
102
106
  const json = fs.readJson(filePath);
103
107
  const idFromFilename = path.basename(filePath, FILE_EXTENSION);
104
108
 
105
109
  if (json._id !== idFromFilename) {
106
- return `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.`;
110
+ return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` };
111
+ }
112
+
113
+ if (json._deleted && json.cht_disable_linked_users) {
114
+ return { delete: json._id };
107
115
  }
108
116
  })
109
- .filter(err => err);
117
+ .filter(Boolean);
118
+ }
119
+
120
+ async function handleUsersAtDeletedFacilities(deletedDocIds) {
121
+ if (!deletedDocIds?.length) {
122
+ return;
123
+ }
124
+
125
+ await assertCoreVersion();
126
+
127
+ const affectedUsers = await getAffectedUsers(deletedDocIds);
128
+ const usernames = affectedUsers.map(userDoc => userDoc.username).join(', ');
129
+ if (affectedUsers.length === 0) {
130
+ trace('No users found needing an update.');
131
+ return;
132
+ }
133
+
134
+ userPrompt.warnPromptAbort(`This operation will update ${affectedUsers.length} user accounts: ${usernames} and cannot be undone. Are you sure you want to continue?`);
135
+ await updateAffectedUsers(affectedUsers);
136
+ }
137
+
138
+ async function assertCoreVersion() {
139
+ const actualCoreVersion = await getValidApiVersion();
140
+ if (semver.lt(actualCoreVersion, '4.7.0-dev')) {
141
+ throw Error(`CHT Core Version 4.7.0 or newer is required to use --disable-users options. Version is ${actualCoreVersion}.`);
142
+ }
143
+
144
+ trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`);
145
+ }
146
+
147
+ async function getAffectedUsers(deletedDocIds) {
148
+ const toPostApiFormat = (apiResponse) => {
149
+ const places = Array.isArray(apiResponse.place) ? apiResponse.place.filter(Boolean) : [apiResponse.place];
150
+ const placeIds = places.map(place => place?._id);
151
+ return {
152
+ username: apiResponse.username,
153
+ place: placeIds,
154
+ };
155
+ };
156
+
157
+ const knownUserDocs = {};
158
+ for (const facilityId of deletedDocIds) {
159
+ const fetchedUserInfos = await api().getUsersAtPlace(facilityId);
160
+ for (const fetchedUserInfo of fetchedUserInfos) {
161
+ const userDoc = knownUserDocs[fetchedUserInfo.username] || toPostApiFormat(fetchedUserInfo);
162
+ removePlace(userDoc, facilityId);
163
+ knownUserDocs[userDoc.username] = userDoc;
164
+ }
165
+ }
166
+
167
+ return Object.values(knownUserDocs);
168
+ }
169
+
170
+ function removePlace(userDoc, placeId) {
171
+ if (Array.isArray(userDoc.place)) {
172
+ userDoc.place = userDoc.place
173
+ .filter(id => id !== placeId);
174
+ } else {
175
+ delete userDoc.place;
176
+ }
177
+ }
178
+
179
+ async function updateAffectedUsers(affectedUsers) {
180
+ let disabledUsers = 0, updatedUsers = 0;
181
+ for (const userDoc of affectedUsers) {
182
+ const shouldDisable = !userDoc.place || userDoc.place?.length === 0;
183
+ if (shouldDisable) {
184
+ trace(`Disabling ${userDoc.username}`);
185
+ await api().disableUser(userDoc.username);
186
+ disabledUsers++;
187
+ } else {
188
+ trace(`Updating ${userDoc.username}`);
189
+ await api().updateUser(userDoc);
190
+ updatedUsers++;
191
+ }
192
+ }
193
+
194
+ info(`${disabledUsers} users disabled. ${updatedUsers} users updated.`);
195
+ }
110
196
 
111
197
  module.exports = {
112
198
  requiresInstance: true,
package/src/lib/api.js CHANGED
@@ -39,6 +39,7 @@ const request = {
39
39
  get: _request('get'),
40
40
  post: _request('post'),
41
41
  put: _request('put'),
42
+ delete: _request('delete'),
42
43
  };
43
44
 
44
45
  const logDeprecatedTransitions = (settings) => {
@@ -98,6 +99,29 @@ const api = {
98
99
  .then(() => updateAppSettings(content));
99
100
  },
100
101
 
102
+ async getUsersAtPlace(facilityId) {
103
+ const result = await request.get({
104
+ uri: `${environment.instanceUrl}/api/v2/users?facility_id=${facilityId}`,
105
+ json: true,
106
+ });
107
+
108
+ return result || [];
109
+ },
110
+
111
+ disableUser(username) {
112
+ return request.delete({
113
+ uri: `${environment.instanceUrl}/api/v1/users/${username}`,
114
+ });
115
+ },
116
+
117
+ updateUser(userDoc) {
118
+ return request.post({
119
+ uri: `${environment.instanceUrl}/api/v1/users/${userDoc.username}`,
120
+ json: true,
121
+ body: userDoc,
122
+ });
123
+ },
124
+
101
125
  createUser(userData) {
102
126
  return request.post({
103
127
  uri: `${environment.instanceUrl}/api/v1/users`,
@@ -34,6 +34,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
34
34
  _id: sourceDoc._id,
35
35
  _rev: sourceDoc._rev,
36
36
  _deleted: true,
37
+ cht_disable_linked_users: !!options.disableUsers,
37
38
  });
38
39
  }
39
40
 
@@ -1,5 +1,6 @@
1
1
  const environment = require('./environment');
2
2
  const readline = require('readline-sync');
3
+ const { warn } = require('./log');
3
4
 
4
5
 
5
6
  /**
@@ -33,8 +34,16 @@ function keyInSelect(items, question, options = {}) {
33
34
  return readline.keyInSelect(items, question, options);
34
35
  }
35
36
 
37
+ function warnPromptAbort(warningMessage) {
38
+ warn(warningMessage);
39
+ if (!keyInYN()) {
40
+ throw new Error('User aborted execution.');
41
+ }
42
+ }
43
+
36
44
  module.exports = {
37
45
  keyInYN,
38
46
  question,
39
- keyInSelect
47
+ keyInSelect,
48
+ warnPromptAbort,
40
49
  };