diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 42670f16..44f5f73d 100644 --- a/src/fn/merge-contacts.js +++ b/src/fn/merge-contacts.js @@ -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, }; @@ -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, }; @@ -63,6 +65,9 @@ ${bold('OPTIONS')} --sources=, 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= Specifies the folder used to store the documents representing the changes in hierarchy. `); diff --git a/src/fn/upload-docs.js b/src/fn/upload-docs.js index 3f78c54f..101b95d3 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -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'); @@ -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) => { @@ -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, diff --git a/src/lib/api.js b/src/lib/api.js index 58b2050c..e11a96a8 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -39,6 +39,7 @@ const request = { get: _request('get'), post: _request('post'), put: _request('put'), + delete: _request('delete'), }; const logDeprecatedTransitions = (settings) => { @@ -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`, diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js index 1dbaf149..036ac676 100644 --- a/src/lib/hierarchy-operations/index.js +++ b/src/lib/hierarchy-operations/index.js @@ -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, }); } diff --git a/src/lib/user-prompt.js b/src/lib/user-prompt.js index 71bd2c21..d1389457 100644 --- a/src/lib/user-prompt.js +++ b/src/lib/user-prompt.js @@ -1,5 +1,6 @@ const environment = require('./environment'); const readline = require('readline-sync'); +const { warn } = require('./log'); /** @@ -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, }; diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js index 03a6a963..6770d846 100644 --- a/test/fn/merge-contacts.spec.js +++ b/test/fn/merge-contacts.spec.js @@ -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: '/', }); diff --git a/test/fn/upload-docs.spec.js b/test/fn/upload-docs.spec.js index dba4a02f..91297639 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -1,41 +1,56 @@ -const { expect, assert } = require('chai'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + const rewire = require('rewire'); const sinon = require('sinon'); -const api = require('../api-stub'); +const apiStub = require('../api-stub'); const environment = require('../../src/lib/environment'); let uploadDocs = rewire('../../src/fn/upload-docs'); const userPrompt = rewire('../../src/lib/user-prompt'); + +const { assert, expect } = chai; +chai.use(chaiAsPromised); let readLine = { keyInYN: () => true }; userPrompt.__set__('readline', readLine); uploadDocs.__set__('userPrompt', userPrompt); -describe('upload-docs', function() { - let fs; +let fs, expectedDocs; + +const API_VERSION_RESPONSE = { status: 200, body: { version: '4.10.0' }}; +describe('upload-docs', function() { beforeEach(() => { sinon.stub(environment, 'isArchiveMode').get(() => false); sinon.stub(environment, 'extraArgs').get(() => undefined); sinon.stub(environment, 'pathToProject').get(() => '.'); sinon.stub(environment, 'force').get(() => false); - api.start(); + apiStub.start(); + expectedDocs = [ + { _id: 'one' }, + { _id: 'two' }, + { _id: 'three' }, + ]; fs = { exists: () => true, - recurseFiles: () => ['one.doc.json', 'two.doc.json', 'three.doc.json'], + recurseFiles: () => expectedDocs.map(doc => `${doc._id}.doc.json`), writeJson: () => {}, - readJson: name => ({ _id: name.substring(0, name.length - '.doc.json'.length) }), + readJson: name => { + const id = name.substring(0, name.length - '.doc.json'.length); + return expectedDocs.find(doc => doc._id === id); + }, }; uploadDocs.__set__('fs', fs); }); afterEach(() => { sinon.restore(); - return api.stop(); + return apiStub.stop(); }); it('should upload docs to pouch', async () => { await assertDbEmpty(); await uploadDocs.execute(); - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); @@ -71,19 +86,17 @@ describe('upload-docs', function() { const bulkDocs = sinon.stub() .onCall(0).throws({ error: 'timeout' }) .returns(Promise.resolve([{}])); - fs.recurseFiles = () => new Array(10).fill('').map((x, i) => `${i}.doc.json`); + expectedDocs = new Array(10).fill('').map((x, i) => ({ _id: i.toString() })); const clock = sinon.useFakeTimers(0); - uploadDocs = rewire('../../src/fn/upload-docs'); - uploadDocs.__set__('userPrompt', userPrompt); - - const imported_date = new Date(0).toISOString(); + const imported_date = new Date().toISOString(); return uploadDocs.__with__({ INITIAL_BATCH_SIZE: 4, + Date, fs, pouch: () => ({ bulkDocs }), })(async () => { await uploadDocs.execute(); - expect(bulkDocs.callCount).to.eq(1 + 10 / 2); + expect(bulkDocs.callCount).to.eq(6); // first failed batch of 4 expect(bulkDocs.args[0][0]).to.deep.eq([ @@ -106,21 +119,13 @@ describe('upload-docs', function() { ]); clock.restore(); - uploadDocs = rewire('../../src/fn/upload-docs'); - uploadDocs.__set__('userPrompt', userPrompt); }); }); it('should throw if user denies the warning', async () => { userPrompt.__set__('readline', { keyInYN: () => false }); - await assertDbEmpty(); - await uploadDocs.execute() - .then(() => { - assert.fail('Expected error to be thrown'); - }) - .catch(err => { - expect(err.message).to.equal('User aborted execution.'); - }); + const actual = uploadDocs.execute(); + await expect(actual).to.eventually.be.rejectedWith('User aborted execution.'); }); it('should not throw if force is set', async () => { @@ -128,12 +133,146 @@ describe('upload-docs', function() { await assertDbEmpty(); sinon.stub(process, 'exit'); await uploadDocs.execute(); - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows.map(doc => doc.id)).to.deep.eq(['one', 'three', 'two']); }); + + describe('--disable-users', () => { + beforeEach(async () => { + sinon.stub(environment, 'extraArgs').get(() => ['--disable-users']); + await assertDbEmpty(); + }); + + const twoPlaces = [ + { _id: 'one' }, + { _id: 'two' }, + ]; + + it('user with single place gets deleted', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: [{ _id: 'one' }] }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + ]); + }); + + it('user with single place gets deleted (old core api format)', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: { _id: 'one' } }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + ]); + }); + + it('users associated with docs without truthy deleteUser attribute are not deleted', async () => { + const writtenDoc = await apiStub.db.put({ _id: 'one' }); + + const oneDoc = expectedDocs[0]; + oneDoc._rev = writtenDoc.rev; + oneDoc._deleted = true; + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + assert.deepEqual(apiStub.requestLog(), []); + }); + + it('user with multiple places gets updated', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + const expectedBody = { + username: 'user1', + place: [ 'two' ], + }; + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'POST', url: '/api/v1/users/user1', body: expectedBody }, + ]); + }); + + it('user with multiple places gets deleted', async () => { + await setupDeletedFacilities('one', 'two'); + const user1Doc = { id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }; + setupApiResponses(1, [user1Doc], [user1Doc]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three']); + + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=two', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + ]); + }); + + it('one user disabled and one updated when single place has multiple users', async () => { + await setupDeletedFacilities('one'); + setupApiResponses(2, [ + { id: 'org.couchdb.user:user1', username: 'user1', place: [{ _id: 'one' }] }, + { id: 'org.couchdb.user:user2', username: 'user2', place: twoPlaces } + ]); + + await uploadDocs.execute(); + const res = await apiStub.db.allDocs(); + expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); + + const expectedUser2 = { + username: 'user2', + place: ['two'], + }; + assert.deepEqual(apiStub.requestLog(), [ + { method: 'GET', url: '/api/deploy-info', body: {} }, + { method: 'GET', url: '/api/v2/users?facility_id=one', body: {} }, + { method: 'DELETE', url: '/api/v1/users/user1', body: {} }, + { method: 'POST', url: '/api/v1/users/user2', body: expectedUser2 }, + ]); + }); + }); }); +function setupApiResponses(writeCount, ...userDocResponseRows) { + const responseBodies = userDocResponseRows.map(body => ({ body })); + const writeResponses = new Array(writeCount).fill({ status: 200 }); + apiStub.giveResponses( + API_VERSION_RESPONSE, + ...responseBodies, + ...writeResponses, + ); +} + +async function setupDeletedFacilities(...docIds) { + for (const id of docIds) { + const writtenDoc = await apiStub.db.put({ _id: id }); + const expected = expectedDocs.find(doc => doc._id === id); + expected._rev = writtenDoc.rev; + expected._deleted = true; + expected.cht_disable_linked_users = true; + } +} + async function assertDbEmpty() { - const res = await api.db.allDocs(); + const res = await apiStub.db.allDocs(); expect(res.rows).to.be.empty; } diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js index de35eaa6..34429c9e 100644 --- a/test/lib/hierarchy-operations/hierarchy-operations.spec.js +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -29,7 +29,7 @@ const reports_by_freetext = { map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" }; -describe('move-contacts', () => { +describe('hierarchy-operations', () => { let pouchDb, scenarioCount = 0; const writtenDocs = []; const getWrittenDoc = docId => { @@ -55,7 +55,7 @@ describe('move-contacts', () => { const updateHierarchyRules = contact_types => upsert('settings', { settings: { contact_types } }); beforeEach(async () => { - pouchDb = new PouchDB(`move-contacts-${scenarioCount++}`); + pouchDb = new PouchDB(`hierarchy-operations-${scenarioCount++}`); await mockHierarchy(pouchDb, { district_1: { @@ -407,7 +407,7 @@ describe('move-contacts', () => { }); // action - await HierarchyOperations(pouchDb).merge(['district_2'], 'district_1'); + await HierarchyOperations(pouchDb, { disableUsers: true }).merge(['district_2'], 'district_1'); // assert expectWrittenDocs([ @@ -421,6 +421,7 @@ describe('move-contacts', () => { expect(getWrittenDoc('district_2')).to.deep.eq({ _id: 'district_2', _deleted: true, + cht_disable_linked_users: true, }); expect(getWrittenDoc('health_center_2')).to.deep.eq({ @@ -498,6 +499,7 @@ describe('move-contacts', () => { expect(getWrittenDoc('patient_2')).to.deep.eq({ _id: 'patient_2', _deleted: true, + cht_disable_linked_users: false, }); expect(getWrittenDoc('pat2')).to.deep.eq({