Skip to content

Commit

Permalink
feat(#648): add --disable-users command for merge-contacts action (#649)
Browse files Browse the repository at this point in the history
When --disable-users option is present during merge-contacts

Look for deleted docs in json_docs folder during upload-docs action
Look for users having facility_id which matches the deleted doc id
Removes the id from the user's list of facility_ids
Updates the user as appropriate (deletes it if no facility_ids remain)
  • Loading branch information
kennsippell authored Dec 16, 2024
1 parent 7062313 commit c78d3fd
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 49 deletions.
5 changes: 5 additions & 0 deletions src/fn/merge-contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
const db = pouch();
const options = {
disableUsers: args.disableUsers,
docDirectoryPath: args.docDirectoryPath,
force: args.force,
};
Expand Down Expand Up @@ -41,6 +42,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
return {
destinationId: args.destination,
sourceIds,
disableUsers: !!args['disable-users'],
docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
force: !!args.force,
};
Expand All @@ -63,6 +65,9 @@ ${bold('OPTIONS')}
--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.
--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.
--docDirectoryPath=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
`);
Expand Down
122 changes: 104 additions & 18 deletions src/fn/upload-docs.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
const path = require('path');
const minimist = require('minimist');
const userPrompt = require('../lib/user-prompt');
const semver = require('semver');

const api = require('../lib/api');
const environment = require('../lib/environment');
const fs = require('../lib/sync-fs');
const { getValidApiVersion } = require('../lib/get-api-version');
const log = require('../lib/log');
const pouch = require('../lib/db');
const progressBar = require('../lib/progress-bar');
const userPrompt = require('../lib/user-prompt');

const { info, trace, warn } = log;

const FILE_EXTENSION = '.doc.json';
const INITIAL_BATCH_SIZE = 100;

const execute = async () => {
async function execute() {
const args = minimist(environment.extraArgs || [], { boolean: true });

const docDir = path.resolve(environment.pathToProject, args.docDirectoryPath || 'json_docs');
Expand All @@ -22,22 +25,23 @@ const execute = async () => {
return Promise.resolve();
}

const filesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION));
const docIdErrors = getErrorsWhereDocIdDiffersFromFilename(filesToUpload);
if (docIdErrors.length > 0) {
throw new Error(`upload-docs: ${docIdErrors.join('\n')}`);
}

const totalCount = filesToUpload.length;
const filenamesToUpload = fs.recurseFiles(docDir).filter(name => name.endsWith(FILE_EXTENSION));
const totalCount = filenamesToUpload.length;
if (totalCount === 0) {
return; // nothing to upload
}

warn(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`);
if (!userPrompt.keyInYN()) {
throw new Error('User aborted execution.');
const analysis = analyseFiles(filenamesToUpload);
const errors = analysis.map(result => result.error).filter(Boolean);
if (errors.length > 0) {
throw new Error(`upload-docs: ${errors.join('\n')}`);
}

userPrompt.warnPromptAbort(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`);

const deletedDocIds = analysis.map(result => result.delete).filter(Boolean);
await handleUsersAtDeletedFacilities(deletedDocIds);

const results = { ok:[], failed:{} };
const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null;
const processNextBatch = async (docFiles, batchSize) => {
Expand Down Expand Up @@ -93,20 +97,102 @@ const execute = async () => {
}
};

return processNextBatch(filesToUpload, INITIAL_BATCH_SIZE);
};
return processNextBatch(filenamesToUpload, INITIAL_BATCH_SIZE);
}

const getErrorsWhereDocIdDiffersFromFilename = filePaths =>
filePaths
function analyseFiles(filePaths) {
return filePaths
.map(filePath => {
const json = fs.readJson(filePath);
const idFromFilename = path.basename(filePath, FILE_EXTENSION);

if (json._id !== idFromFilename) {
return `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.`;
return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` };
}

if (json._deleted && json.cht_disable_linked_users) {
return { delete: json._id };
}
})
.filter(err => err);
.filter(Boolean);
}

async function handleUsersAtDeletedFacilities(deletedDocIds) {
if (!deletedDocIds?.length) {
return;
}

await assertCoreVersion();

const affectedUsers = await getAffectedUsers(deletedDocIds);
const usernames = affectedUsers.map(userDoc => userDoc.username).join(', ');
if (affectedUsers.length === 0) {
trace('No users found needing an update.');
return;
}

userPrompt.warnPromptAbort(`This operation will update ${affectedUsers.length} user accounts: ${usernames} and cannot be undone. Are you sure you want to continue?`);
await updateAffectedUsers(affectedUsers);
}

async function assertCoreVersion() {
const actualCoreVersion = await getValidApiVersion();
if (semver.lt(actualCoreVersion, '4.7.0-dev')) {
throw Error(`CHT Core Version 4.7.0 or newer is required to use --disable-users options. Version is ${actualCoreVersion}.`);
}

trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`);
}

async function getAffectedUsers(deletedDocIds) {
const toPostApiFormat = (apiResponse) => {
const places = Array.isArray(apiResponse.place) ? apiResponse.place.filter(Boolean) : [apiResponse.place];
const placeIds = places.map(place => place?._id);
return {
username: apiResponse.username,
place: placeIds,
};
};

const knownUserDocs = {};
for (const facilityId of deletedDocIds) {
const fetchedUserInfos = await api().getUsersAtPlace(facilityId);
for (const fetchedUserInfo of fetchedUserInfos) {
const userDoc = knownUserDocs[fetchedUserInfo.username] || toPostApiFormat(fetchedUserInfo);
removePlace(userDoc, facilityId);
knownUserDocs[userDoc.username] = userDoc;
}
}

return Object.values(knownUserDocs);
}

function removePlace(userDoc, placeId) {
if (Array.isArray(userDoc.place)) {
userDoc.place = userDoc.place
.filter(id => id !== placeId);
} else {
delete userDoc.place;
}
}

async function updateAffectedUsers(affectedUsers) {
let disabledUsers = 0, updatedUsers = 0;
for (const userDoc of affectedUsers) {
const shouldDisable = !userDoc.place || userDoc.place?.length === 0;
if (shouldDisable) {
trace(`Disabling ${userDoc.username}`);
await api().disableUser(userDoc.username);
disabledUsers++;
} else {
trace(`Updating ${userDoc.username}`);
await api().updateUser(userDoc);
updatedUsers++;
}
}

info(`${disabledUsers} users disabled. ${updatedUsers} users updated.`);
}

module.exports = {
requiresInstance: true,
Expand Down
24 changes: 24 additions & 0 deletions src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const request = {
get: _request('get'),
post: _request('post'),
put: _request('put'),
delete: _request('delete'),
};

const logDeprecatedTransitions = (settings) => {
Expand Down Expand Up @@ -98,6 +99,29 @@ const api = {
.then(() => updateAppSettings(content));
},

async getUsersAtPlace(facilityId) {
const result = await request.get({
uri: `${environment.instanceUrl}/api/v2/users?facility_id=${facilityId}`,
json: true,
});

return result || [];
},

disableUser(username) {
return request.delete({
uri: `${environment.instanceUrl}/api/v1/users/${username}`,
});
},

updateUser(userDoc) {
return request.post({
uri: `${environment.instanceUrl}/api/v1/users/${userDoc.username}`,
json: true,
body: userDoc,
});
},

createUser(userData) {
return request.post({
uri: `${environment.instanceUrl}/api/v1/users`,
Expand Down
1 change: 1 addition & 0 deletions src/lib/hierarchy-operations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
_id: sourceDoc._id,
_rev: sourceDoc._rev,
_deleted: true,
cht_disable_linked_users: !!options.disableUsers,
});
}

Expand Down
11 changes: 10 additions & 1 deletion src/lib/user-prompt.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const environment = require('./environment');
const readline = require('readline-sync');
const { warn } = require('./log');


/**
Expand Down Expand Up @@ -33,8 +34,16 @@ function keyInSelect(items, question, options = {}) {
return readline.keyInSelect(items, question, options);
}

function warnPromptAbort(warningMessage) {
warn(warningMessage);
if (!keyInYN()) {
throw new Error('User aborted execution.');
}
}

module.exports = {
keyInYN,
question,
keyInSelect
keyInSelect,
warnPromptAbort,
};
1 change: 1 addition & 0 deletions test/fn/merge-contacts.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('merge-contacts', () => {
expect(parseExtraArgs(__dirname, args)).to.deep.eq({
sourceIds: ['food', 'is', 'tasty'],
destinationId: 'bar',
disableUsers: false,
force: true,
docDirectoryPath: '/',
});
Expand Down
Loading

0 comments on commit c78d3fd

Please sign in to comment.