diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js index 42670f16..b8973641 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. + --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 82ff738e..dc086827 100644 --- a/src/fn/upload-docs.js +++ b/src/fn/upload-docs.js @@ -1,9 +1,11 @@ const path = require('path'); const minimist = require('minimist'); +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'); @@ -37,10 +39,8 @@ async function execute() { warnAndPrompt(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`); - if (args['disable-users']) { - const deletedDocIds = analysis.map(result => result.delete).filter(Boolean); - await handleUsersAtDeletedFacilities(deletedDocIds); - } + 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; @@ -117,7 +117,7 @@ function analyseFiles(filePaths) { return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` }; } - if (json._deleted) { + if (json._deleted && json.cht_disable_linked_users) { return { delete: json._id }; } }) @@ -125,9 +125,12 @@ function analyseFiles(filePaths) { } async function handleUsersAtDeletedFacilities(deletedDocIds) { + 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; } @@ -135,14 +138,24 @@ async function handleUsersAtDeletedFacilities(deletedDocIds) { 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) => ({ - _id: apiResponse.id, - _rev: apiResponse.rev, - username: apiResponse.username, - place: apiResponse.place?.filter(Boolean).map(place => place._id), - }); + 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) { 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/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 8dd57c55..10006238 100644 --- a/test/fn/upload-docs.spec.js +++ b/test/fn/upload-docs.spec.js @@ -1,4 +1,6 @@ -const { expect, assert } = require('chai'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + const rewire = require('rewire'); const sinon = require('sinon'); @@ -6,12 +8,17 @@ 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); 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); @@ -41,6 +48,8 @@ describe('upload-docs', function() { }); it('should upload docs to pouch', async () => { + apiStub.giveResponses(API_VERSION_RESPONSE); + await assertDbEmpty(); await uploadDocs.execute(); const res = await apiStub.db.allDocs(); @@ -82,6 +91,7 @@ describe('upload-docs', function() { expectedDocs = new Array(10).fill('').map((x, i) => ({ _id: i.toString() })); const clock = sinon.useFakeTimers(0); const imported_date = new Date().toISOString(); + apiStub.giveResponses(API_VERSION_RESPONSE); return uploadDocs.__with__({ INITIAL_BATCH_SIZE: 4, Date, @@ -117,17 +127,12 @@ describe('upload-docs', function() { 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 () => { + apiStub.giveResponses(API_VERSION_RESPONSE); userPrompt.__set__('environment', { force: () => true }); await assertDbEmpty(); sinon.stub(process, 'exit'); @@ -156,11 +161,43 @@ describe('upload-docs', function() { 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' }); + apiStub.giveResponses(API_VERSION_RESPONSE); + + 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(), [ + { method: 'GET', url: '/api/deploy-info', body: {} } + ]); + }); + it('user with multiple places gets updated', async () => { await setupDeletedFacilities('one'); setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }]); @@ -170,11 +207,11 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); const expectedBody = { - _id: 'org.couchdb.user:user1', 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 }, ]); @@ -190,13 +227,14 @@ describe('upload-docs', function() { 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('two users disabled when single place has multiple users', async () => { + 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' }] }, @@ -208,11 +246,11 @@ describe('upload-docs', function() { expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']); const expectedUser2 = { - _id: 'org.couchdb.user:user2', 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 }, @@ -225,6 +263,7 @@ 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, ); @@ -236,7 +275,7 @@ async function setupDeletedFacilities(...docIds) { const expected = expectedDocs.find(doc => doc._id === id); expected._rev = writtenDoc.rev; expected._deleted = true; - expected.disableUsers = true; + expected.cht_disable_linked_users = true; } } 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({