diff --git a/api/src/controllers/users.js b/api/src/controllers/users.js index 48391920d76..4c5978bbcac 100644 --- a/api/src/controllers/users.js +++ b/api/src/controllers/users.js @@ -112,7 +112,11 @@ const getAppUrl = (req) => `${req.protocol}://${req.hostname}`; const getUserList = async (req) => { await auth.check(req, 'can_view_users'); - return await users.getList(); + const filters = { + facilityId: req.query?.facility_id, + contactId: req.query?.contact_id, + }; + return await users.getList(filters); }; const getType = user => { @@ -233,7 +237,7 @@ module.exports = { v2: { get: async (req, res) => { try { - const body = await getUserList(req, res); + const body = await getUserList(req); res.json(body); } catch (err) { serverUtils.error(err, req, res); diff --git a/api/src/migrations/add-contact-id-to-user-docs.js b/api/src/migrations/add-contact-id-to-user-docs.js new file mode 100644 index 00000000000..042c810db83 --- /dev/null +++ b/api/src/migrations/add-contact-id-to-user-docs.js @@ -0,0 +1,55 @@ +const db = require('../db'); +const logger = require('../logger'); + +const BATCH_SIZE = 100; + +const getUserSettingsDocs = async (skip = 0) => db.medic.query('medic-client/doc_by_type', { + include_docs: true, + limit: BATCH_SIZE, + key: ['user-settings'], + skip, +}).then(({ rows }) => rows.map(({ doc }) => doc)); + +const getUpdatedUserDoc = (userSettingsDocs) => (userDoc, index) => { + const { _id, contact_id } = userSettingsDocs[index]; + if (!userDoc) { + logger.warn(`Could not find user with id "${_id}". Skipping it.`); + return null; + } + return { ...userDoc, contact_id }; +}; + +const updateUsersDatabase = async (userSettingsDocs) => db.users.allDocs({ + include_docs: true, + keys: userSettingsDocs.map(doc => doc._id), +}).then(({ rows }) => { + const updatedUsersDocs = rows + .map(({ doc }) => doc) + .map(getUpdatedUserDoc(userSettingsDocs)) + .filter(Boolean); + if (!updatedUsersDocs.length) { + return; + } + return db.users.bulkDocs(updatedUsersDocs); +}); + +const runBatch = async (skip = 0) => { + const userSettingsDocs = await getUserSettingsDocs(skip); + if (!userSettingsDocs.length) { + return; + } + + await updateUsersDatabase(userSettingsDocs); + + if (userSettingsDocs.length < BATCH_SIZE) { + return; + } + + return runBatch(skip + BATCH_SIZE); +}; + +module.exports = { + name: 'add-contact-id-to-user-docs', + created: new Date(2024, 5, 2), + run: runBatch, +}; diff --git a/api/src/services/setup/databases.js b/api/src/services/setup/databases.js index 37160c699a4..57781abb85d 100644 --- a/api/src/services/setup/databases.js +++ b/api/src/services/setup/databases.js @@ -33,6 +33,11 @@ const DATABASES = [ db: db.medicUsersMeta, jsonFileName: 'users-meta.json', }, + { + name: `${environment.db}-users`, + db: db.users, + jsonFileName: 'users.json', + }, ]; module.exports = { diff --git a/api/tests/integration/migrations/add-contact-id-to-user-docs.js b/api/tests/integration/migrations/add-contact-id-to-user-docs.js new file mode 100644 index 00000000000..03da1982bec --- /dev/null +++ b/api/tests/integration/migrations/add-contact-id-to-user-docs.js @@ -0,0 +1,115 @@ +const { expect } = require('chai'); +const utils = require('./utils'); +const db = require('../../../src/db'); +const logger = require('../../../src/logger'); +const sinon = require('sinon'); + +const createUserSettingsDoc = (id, contactId) => { + const userSettings = { + _id: id, + name: id.split(':')[1], + type: 'user-settings', + roles: ['chw'], + facility_id: 'abc' + }; + if (contactId) { + userSettings.contact_id = contactId; + } + return userSettings; +}; + +const createUserDoc = userSettings => { + return { + ...userSettings, + type: 'user', + contact_id: null, + }; +}; + +const writeUserDocs = userDocs => db.users.bulkDocs(userDocs); + +const getUserDoc = async (id) => db.users.get(id); + +describe('add-contact-id-to-user migration', function() { + afterEach(() => { + sinon.restore(); + utils.tearDown(); + }); + + it('migrates the contact_id value from user-settings to _users for all users', async () => { + const userDocTuples = Array + .from({ length: 299 }, (_, i) => i) + .map(i => { + const userSettingsDoc = createUserSettingsDoc(`org.couchdb.user:test-chw-${i}`, `contact-${i}`); + return [ + userSettingsDoc, + createUserDoc(userSettingsDoc) + ]; + }); + const userSettingsDocs = userDocTuples.map(([ userSettingsDoc ]) => userSettingsDoc); + await utils.initDb(userSettingsDocs); + await writeUserDocs(userDocTuples.map(([, userDoc]) => userDoc)); + + await utils.runMigration('add-contact-id-to-user-docs'); + + await utils.assertDb(userSettingsDocs); + for (const [userSettingsDoc, userDoc] of userDocTuples) { + const updatedUserDoc = await getUserDoc(userDoc._id); + expect(updatedUserDoc).to.deep.include({ ...userDoc, contact_id: userSettingsDoc.contact_id }); + } + }); + + it('skips users that do not exist in _users', async () => { + const userSettingsDocMissing = createUserSettingsDoc('org.couchdb.user:missing-chw', 'contact'); + const userSettingsDocDeleted = createUserSettingsDoc('org.couchdb.user:user-deleted', 'contact'); + await utils.initDb([ userSettingsDocMissing, userSettingsDocDeleted ]); + const userDoc = { + ...createUserDoc(userSettingsDocDeleted), + _deleted: true + }; + await writeUserDocs([userDoc]); + sinon.spy(logger, 'warn'); + + await utils.runMigration('add-contact-id-to-user-docs'); + + expect(logger.warn.calledTwice).to.equal(true); + expect(logger.warn.firstCall.args[0]).to + .equal(`Could not find user with id "${userSettingsDocMissing._id}". Skipping it.`); + expect(logger.warn.secondCall.args[0]).to + .equal(`Could not find user with id "${userSettingsDocDeleted._id}". Skipping it.`); + await utils.assertDb([ userSettingsDocMissing, userSettingsDocDeleted ]); + }); + + it('overwrites any existing contact_id value in _users', async () => { + const userSettingsDocs = [ + createUserSettingsDoc('org.couchdb.user:different-contact', 'contact'), + createUserSettingsDoc('org.couchdb.user:no-contact') + ]; + await utils.initDb(userSettingsDocs); + const userDocs = [ + { + ...createUserDoc(userSettingsDocs[0]), + contact_id: 'old-contact' + }, + { + ...createUserDoc(userSettingsDocs[1]), + contact_id: 'old-contact', + facility_id: 'different-facility' + }, + ]; + await writeUserDocs(userDocs); + + await utils.runMigration('add-contact-id-to-user-docs'); + + await utils.assertDb(userSettingsDocs); + const updatedUserDoc0 = await getUserDoc(userDocs[0]._id); + expect(updatedUserDoc0).to.deep.include({ ...userDocs[0], contact_id: userSettingsDocs[0].contact_id }); + const updatedUserDoc1 = await getUserDoc(userDocs[1]._id); + const expectedUserDoc1 = { ...userDocs[1] }; + delete expectedUserDoc1.contact_id; + expect(updatedUserDoc1).to.deep.include(expectedUserDoc1); + expect(updatedUserDoc1.contact_id).to.be.undefined; + // The _users doc facility_id will not be updated + expect(updatedUserDoc1.facility_id).to.equal('different-facility'); + }); +}); diff --git a/api/tests/integration/migrations/utils.js b/api/tests/integration/migrations/utils.js index 8d64d86bcfc..649b3088dc8 100644 --- a/api/tests/integration/migrations/utils.js +++ b/api/tests/integration/migrations/utils.js @@ -155,9 +155,11 @@ const matchDbs = (expected, actual) => { const realMedicDb = db.medic; const realSentinelDb = db.sentinel; +const realUsersDb = db.users; const switchToRealDbs = () => { db.medic = realMedicDb; db.sentinel = realSentinelDb; + db.users = realUsersDb; }; const switchToTestDbs = () => { @@ -167,6 +169,9 @@ const switchToTestDbs = () => { db.sentinel = new PouchDB( realSentinelDb.name.replace(/medic-sentinel$/, 'medic-sentinel-test') ); + db.users = new PouchDB( + realUsersDb.name.replace(/_users$/, 'users-test') + ); }; const initDb = content => { diff --git a/api/tests/mocha/controllers/users.spec.js b/api/tests/mocha/controllers/users.spec.js index dbcb284191f..f2091817477 100644 --- a/api/tests/mocha/controllers/users.spec.js +++ b/api/tests/mocha/controllers/users.spec.js @@ -31,15 +31,24 @@ describe('Users Controller', () => { afterEach(() => sinon.restore()); describe('get users list', () => { + let userList; beforeEach(() => { + userList = [ + { id: 'org.couchdb.user:admin', roles: ['_admin'] }, + { + id: 'org.couchdb.user:chw', + roles: ['chw', 'district-admin'], + contact: { + _id: 'chw-contact', + parent: { _id: 'chw-facility' }, + } + }, + { id: 'org.couchdb.user:unknown' }, + ]; req = { }; res = { json: sinon.stub() }; - sinon.stub(users, 'getList').resolves([ - { id: 'org.couchdb.user:admin', roles: [ '_admin' ] }, - { id: 'org.couchdb.user:chw', roles: [ 'chw', 'district-admin' ] }, - { id: 'org.couchdb.user:unknown' }, - ]); + sinon.stub(users, 'getList').resolves(userList); }); describe('v1', () => { @@ -69,14 +78,13 @@ describe('Users Controller', () => { }); describe('v2', () => { - it('rejects if not permitted', async () => { sinon.stub(auth, 'check').rejects(new Error('nope')); await controller.v2.get(req, res); chai.expect(serverUtils.error.callCount).to.equal(1); }); - it('gets the list of users', async () => { + it('gets the list of users without filters', async () => { sinon.stub(auth, 'check').resolves(); await controller.v2.get(req, res); @@ -92,6 +100,59 @@ describe('Users Controller', () => { chai.expect(result[2].roles).to.be.undefined; }); + it('gets the list of users with facility_id filter', async () => { + sinon.stub(auth, 'check').resolves(); + users.getList.resolves([userList[1]]); + req.query = { + facility_id: 'chw-facility', + unsupported: 'nope', + contactId: 'not supported either', + this_wont_work: 123, + }; + + await controller.v2.get(req, res); + chai.expect(users.getList.firstCall.args[0]) + .to.deep.equal({ facilityId: 'chw-facility', contactId: undefined }); + const result = res.json.args[0][0]; + chai.expect(result.length).to.equal(1); + chai.expect(result[0].id).to.equal('org.couchdb.user:chw'); + chai.expect(result[0].type).to.be.undefined; + chai.expect(result[0].roles).to.deep.equal(['chw', 'district-admin']); + chai.expect(result[0].contact._id).to.equal('chw-contact'); + chai.expect(result[0].contact.parent._id).to.equal('chw-facility'); + }); + + it('gets the list of users with facility_id and contact_id filters', async () => { + sinon.stub(auth, 'check').resolves(); + users.getList.resolves([userList[1]]); + req.query = { facility_id: 'chw-facility', contact_id: 'chw-contact' }; + + await controller.v2.get(req, res); + chai.expect(users.getList.firstCall.args[0]).to.deep.equal({ + contactId: 'chw-contact', + facilityId: 'chw-facility', + }); + const result = res.json.args[0][0]; + chai.expect(result.length).to.equal(1); + chai.expect(result[0].id).to.equal('org.couchdb.user:chw'); + chai.expect(result[0].type).to.be.undefined; + chai.expect(result[0].roles).to.deep.equal(['chw', 'district-admin']); + chai.expect(result[0].contact._id).to.equal('chw-contact'); + chai.expect(result[0].contact.parent._id).to.equal('chw-facility'); + }); + + it('gets the list of users and ignores unexpected filters', async () => { + sinon.stub(auth, 'check').resolves(); + req.query = { roles: ['chw'], name: 'admin' }; + + await controller.v2.get(req, res); + chai.expect(users.getList.firstCall.args[0]).to.deep.equal({ facilityId: undefined, contactId: undefined }); + const result = res.json.args[0][0]; + chai.expect(result.length).to.equal(3); + chai.expect(result[0].id).to.equal('org.couchdb.user:admin'); + chai.expect(result[1].id).to.equal('org.couchdb.user:chw'); + chai.expect(result[2].id).to.equal('org.couchdb.user:unknown'); + }); }); }); diff --git a/api/tests/mocha/migrations/add-contact-id-to-user-docs.spec.js b/api/tests/mocha/migrations/add-contact-id-to-user-docs.spec.js new file mode 100644 index 00000000000..d9023919764 --- /dev/null +++ b/api/tests/mocha/migrations/add-contact-id-to-user-docs.spec.js @@ -0,0 +1,178 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const db = require('../../../src/db'); +const logger = require('../../../src/logger'); +const migration = require('../../../src/migrations/add-contact-id-to-user-docs'); + +const BATCH_SIZE = 100; + +const createUserSettingsDoc = (id, contactId) => { + const userSettings = { + _id: id, + type: 'user-settings', + }; + if (contactId) { + userSettings.contact_id = contactId; + } + return userSettings; +}; + +const createUserDoc = id => { + return { + _id: id, + type: 'user', + contact_id: null, + }; +}; + +const createCouchResponse = docs => ({ rows: docs.map(doc => ({ doc })) }); + +const assertDocByTypeQueryArgs = (args, skip) => expect(args).to.deep.equal([ + 'medic-client/doc_by_type', + { + include_docs: true, + limit: BATCH_SIZE, + key: ['user-settings'], + skip + } +]); + +const getExpectedUserDoc = userSettingsDocs => (userDoc, index) => { + return { + ...userDoc, + contact_id: userSettingsDocs[index].contact_id + }; +}; + +describe('add-contact-id-to-user-docs migration', () => { + let medicQuery; + let usersAllDocs; + let usersBulkDocs; + let loggerWarn; + + beforeEach(() => { + medicQuery = sinon.stub(db.medic, 'query'); + usersAllDocs = sinon.stub(db.users, 'allDocs'); + usersBulkDocs = sinon.stub(db.users, 'bulkDocs'); + loggerWarn = sinon.spy(logger, 'warn'); + }); + + afterEach(() => sinon.restore()); + + it('has basic properties', () => { + const expectedCreationDate = new Date(2024, 5, 2).toDateString(); + + expect(migration.name).to.equal('add-contact-id-to-user-docs'); + expect(migration.created).to.exist; + expect(migration.created.toDateString()).to.equal(expectedCreationDate); + expect(migration.run).to.be.a('function'); + }); + + it('migrates the contact_id value from user-settings to _users', async () => { + const userSettingsDoc = createUserSettingsDoc('org.couchdb.user:test-chw-1', 'contact-1'); + medicQuery.resolves(createCouchResponse([userSettingsDoc])); + const userDoc = createUserDoc(userSettingsDoc._id); + usersAllDocs.resolves(createCouchResponse([userDoc])); + + await migration.run(); + + expect(medicQuery.calledOnce).to.be.true; + assertDocByTypeQueryArgs(medicQuery.args[0], 0); + expect(usersAllDocs.calledOnce).to.be.true; + expect(usersAllDocs.args[0]).to.deep.equal([{ include_docs: true, keys: [userSettingsDoc._id] }]); + expect(usersBulkDocs.calledOnce).to.be.true; + expect(usersBulkDocs.args[0]).to.deep.equal([[{ ...userDoc, contact_id: userSettingsDoc.contact_id }]]); + }); + + it('migrates the contact_id value for all batches', async () => { + const userSettingsFirstBatch = Array.from( + { length: BATCH_SIZE }, + (_, i) => createUserSettingsDoc(`org.couchdb.user:test-chw-${i}`, `contact-${i}`) + ); + const userSettingsSecondBatch = Array.from( + { length: BATCH_SIZE }, + (_, i) => createUserSettingsDoc(`org.couchdb.user:test-chw-11${i}`, `contact-11${i}`) + ); + const userSettingsThirdBatch = [createUserSettingsDoc(`org.couchdb.user:test-chw-222`, `contact-222`)]; + medicQuery.onFirstCall().resolves(createCouchResponse(userSettingsFirstBatch)); + medicQuery.onSecondCall().resolves(createCouchResponse(userSettingsSecondBatch)); + medicQuery.onThirdCall().resolves(createCouchResponse(userSettingsThirdBatch)); + + const userDocsFirstBatch = userSettingsFirstBatch.map(doc => createUserDoc(doc._id)); + const userDocsSecondBatch = userSettingsSecondBatch.map(doc => createUserDoc(doc._id)); + const userDocsThirdBatch = userSettingsThirdBatch.map(doc => createUserDoc(doc._id)); + usersAllDocs.onFirstCall().resolves(createCouchResponse(userDocsFirstBatch)); + usersAllDocs.onSecondCall().resolves(createCouchResponse(userDocsSecondBatch)); + usersAllDocs.onThirdCall().resolves(createCouchResponse(userDocsThirdBatch)); + + await migration.run(); + + expect(medicQuery.calledThrice).to.be.true; + assertDocByTypeQueryArgs(medicQuery.args[0], 0); + assertDocByTypeQueryArgs(medicQuery.args[1], BATCH_SIZE); + assertDocByTypeQueryArgs(medicQuery.args[2], BATCH_SIZE * 2); + expect(usersAllDocs.calledThrice).to.be.true; + expect(usersAllDocs.args[0]).to.deep.equal([{ + include_docs: true, + keys: userSettingsFirstBatch.map(doc => doc._id) + }]); + expect(usersAllDocs.args[1]).to.deep.equal([{ + include_docs: true, + keys: userSettingsSecondBatch.map(doc => doc._id) + }]); + expect(usersAllDocs.args[2]).to.deep.equal([{ + include_docs: true, + keys: userSettingsThirdBatch.map(doc => doc._id) + }]); + expect(usersBulkDocs.calledThrice).to.be.true; + expect(usersBulkDocs.args[0]).to.deep.equal([userDocsFirstBatch.map(getExpectedUserDoc(userSettingsFirstBatch))]); + expect(usersBulkDocs.args[1]).to.deep.equal([userDocsSecondBatch.map(getExpectedUserDoc(userSettingsSecondBatch))]); + expect(usersBulkDocs.args[2]).to.deep.equal([userDocsThirdBatch.map(getExpectedUserDoc(userSettingsThirdBatch))]); + }); + + it('does nothing if no user-settings are found', async () => { + medicQuery.resolves(createCouchResponse([])); + + await migration.run(); + + expect(medicQuery.calledOnce).to.be.true; + assertDocByTypeQueryArgs(medicQuery.args[0], 0); + expect(usersAllDocs.notCalled).to.be.true; + expect(usersBulkDocs.notCalled).to.be.true; + }); + + it('does nothing if no _users docs are found', async () => { + const userSettingsDoc = createUserSettingsDoc('org.couchdb.user:test-chw-1', 'contact-1'); + medicQuery.resolves(createCouchResponse([userSettingsDoc])); + usersAllDocs.resolves(createCouchResponse([null])); + + await migration.run(); + + expect(medicQuery.calledOnce).to.be.true; + assertDocByTypeQueryArgs(medicQuery.args[0], 0); + expect(usersAllDocs.calledOnce).to.be.true; + expect(usersAllDocs.args[0]).to.deep.equal([{ include_docs: true, keys: [userSettingsDoc._id] }]); + expect(usersBulkDocs.notCalled).to.be.true; + expect(loggerWarn.calledOnce).to.be.true; + expect(loggerWarn.args[0]).to.deep.equal([`Could not find user with id "${userSettingsDoc._id}". Skipping it.`]); + }); + + it('overwrites any existing contact_id value in _users', async () => { + const userSettingsDoc = createUserSettingsDoc('org.couchdb.user:test-chw-1', 'contact-1'); + medicQuery.resolves(createCouchResponse([userSettingsDoc])); + const userDoc = createUserDoc(userSettingsDoc._id); + usersAllDocs.resolves(createCouchResponse([{ + ...userDoc, + contact_id: 'old-contact' + }])); + + await migration.run(); + + expect(medicQuery.calledOnce).to.be.true; + assertDocByTypeQueryArgs(medicQuery.args[0], 0); + expect(usersAllDocs.calledOnce).to.be.true; + expect(usersAllDocs.args[0]).to.deep.equal([{ include_docs: true, keys: [userSettingsDoc._id] }]); + expect(usersBulkDocs.calledOnce).to.be.true; + expect(usersBulkDocs.args[0]).to.deep.equal([[{ ...userDoc, contact_id: userSettingsDoc.contact_id }]]); + }); +}); diff --git a/api/tests/mocha/services/setup/databases.spec.js b/api/tests/mocha/services/setup/databases.spec.js index 8e90ea708ae..7b89a5fe784 100644 --- a/api/tests/mocha/services/setup/databases.spec.js +++ b/api/tests/mocha/services/setup/databases.spec.js @@ -38,7 +38,12 @@ describe('databases', () => { name: 'thedb-users-meta', db: db.medicUsersMeta, jsonFileName: 'users-meta.json', - } + }, + { + name: `thedb-users`, + db: db.users, + jsonFileName: 'users.json', + }, ]); }); }); diff --git a/api/tests/mocha/services/setup/utils.spec.js b/api/tests/mocha/services/setup/utils.spec.js index c67c69cc147..8b9a4475bce 100644 --- a/api/tests/mocha/services/setup/utils.spec.js +++ b/api/tests/mocha/services/setup/utils.spec.js @@ -49,24 +49,27 @@ describe('Setup utils', () => { ddocsService.getStagedDdocs.withArgs(DATABASES[1]).resolves([{ _id: 'two' }]); ddocsService.getStagedDdocs.withArgs(DATABASES[2]).resolves([{ _id: 'three' }]); ddocsService.getStagedDdocs.withArgs(DATABASES[3]).resolves([{ _id: 'four' }]); + ddocsService.getStagedDdocs.withArgs(DATABASES[4]).resolves([{ _id: 'five' }]); db.saveDocs.resolves(); await utils.__get__('deleteStagedDdocs')(); - expect(ddocsService.getStagedDdocs.callCount).to.equal(4); + expect(ddocsService.getStagedDdocs.callCount).to.equal(5); expect(ddocsService.getStagedDdocs.args).to.deep.equal([ [DATABASES[0]], [DATABASES[1]], [DATABASES[2]], [DATABASES[3]], + [DATABASES[4]], ]); - expect(db.saveDocs.callCount).to.equal(4); + expect(db.saveDocs.callCount).to.equal(5); expect(db.saveDocs.args).to.deep.equal([ [DATABASES[0].db, [{ _id: 'one', _deleted: true }]], [DATABASES[1].db, [{ _id: 'two', _deleted: true }]], [DATABASES[2].db, [{ _id: 'three', _deleted: true }]], [DATABASES[3].db, [{ _id: 'four', _deleted: true }]], + [DATABASES[4].db, [{ _id: 'five', _deleted: true }]], ]); }); @@ -128,17 +131,19 @@ describe('Setup utils', () => { ddocsService.getStagedDdocs.withArgs(DATABASES[1]).resolves([]); ddocsService.getStagedDdocs.withArgs(DATABASES[2]).resolves([{ _id: 'three' }]); ddocsService.getStagedDdocs.withArgs(DATABASES[3]).resolves([]); + ddocsService.getStagedDdocs.withArgs(DATABASES[4]).resolves([]); db.saveDocs.resolves(); await utils.__get__('deleteStagedDdocs')(); - expect(ddocsService.getStagedDdocs.callCount).to.equal(4); + expect(ddocsService.getStagedDdocs.callCount).to.equal(5); expect(ddocsService.getStagedDdocs.args).to.deep.equal([ [DATABASES[0]], [DATABASES[1]], [DATABASES[2]], [DATABASES[3]], + [DATABASES[4]], ]); expect(db.saveDocs.callCount).to.equal(2); expect(db.saveDocs.args).to.deep.equal([ @@ -154,6 +159,7 @@ describe('Setup utils', () => { mockDb(db.sentinel); mockDb(db.medicLogs); mockDb(db.medicUsersMeta); + mockDb(db.users); utils.cleanup(); @@ -161,11 +167,13 @@ describe('Setup utils', () => { expect(db.sentinel.compact.callCount).to.equal(1); expect(db.medicLogs.compact.callCount).to.equal(1); expect(db.medicUsersMeta.compact.callCount).to.equal(1); + expect(db.users.compact.callCount).to.equal(1); expect(db.medic.viewCleanup.callCount).to.equal(1); expect(db.sentinel.viewCleanup.callCount).to.equal(1); expect(db.medicLogs.viewCleanup.callCount).to.equal(1); expect(db.medicUsersMeta.viewCleanup.callCount).to.equal(1); + expect(db.users.viewCleanup.callCount).to.equal(1); }); it('should catch errors', async () => { @@ -173,6 +181,7 @@ describe('Setup utils', () => { mockDb(db.sentinel); mockDb(db.medicLogs); mockDb(db.medicUsersMeta); + mockDb(db.users); db.sentinel.compact.rejects({ some: 'error' }); utils.cleanup(); @@ -208,19 +217,22 @@ describe('Setup utils', () => { { _id: '_design/meta1', views: { usersmeta1: {} } }, { _id: '_design/meta2', views: { usersmeta2: {} } }, ]; + const usersDdocs = [{ _id: '_design/users', views: { users1: {} } }]; fs.promises.readFile.withArgs('localDdocs/medic.json').resolves(genDdocsJson(medicDdocs)); fs.promises.readFile.withArgs('localDdocs/sentinel.json').resolves(genDdocsJson(sentinelDdocs)); fs.promises.readFile.withArgs('localDdocs/logs.json').resolves(genDdocsJson(logsDdocs)); fs.promises.readFile.withArgs('localDdocs/users-meta.json').resolves(genDdocsJson(usersMetaDdocs)); + fs.promises.readFile.withArgs('localDdocs/users.json').resolves(genDdocsJson(usersDdocs)); const result = await utils.getDdocDefinitions(); - expect(result.size).to.equal(4); + expect(result.size).to.equal(5); expect(result.get(DATABASES[0])).to.deep.equal(medicDdocs); expect(result.get(DATABASES[1])).to.deep.equal(sentinelDdocs); expect(result.get(DATABASES[2])).to.deep.equal(logsDdocs); expect(result.get(DATABASES[3])).to.deep.equal(usersMetaDdocs); + expect(result.get(DATABASES[4])).to.deep.equal(usersDdocs); expect(db.builds.get.callCount).to.equal(0); expect(fs.promises.readFile.args).to.deep.equal([ @@ -228,6 +240,7 @@ describe('Setup utils', () => { ['localDdocs/sentinel.json', 'utf-8'], ['localDdocs/logs.json', 'utf-8'], ['localDdocs/users-meta.json', 'utf-8'], + ['localDdocs/users.json', 'utf-8'], ]); }); @@ -439,12 +452,13 @@ describe('Setup utils', () => { { _id: '_design/meta1', views: { usersmeta1: {} } }, { _id: '_design/meta2', views: { usersmeta2: {} } }, ]); + ddocDefinitions.set(DATABASES[4], [{ _id: '_design/users', views: { users1: {} } }]); sinon.stub(db, 'saveDocs').resolves(); await utils.saveStagedDdocs(ddocDefinitions); - expect(db.saveDocs.callCount).to.equal(4); + expect(db.saveDocs.callCount).to.equal(5); expect(db.saveDocs.args[0]).to.deep.equal([db.medic, [ { _id: '_design/:staged:medic', views: { medic1: {}, medic2: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:medic-client', views: { client1: {}, client2: {} }, deploy_info: deployInfo }, @@ -459,6 +473,9 @@ describe('Setup utils', () => { { _id: '_design/:staged:meta1', views: { usersmeta1: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:meta2', views: { usersmeta2: {} }, deploy_info: deployInfo }, ]]); + expect(db.saveDocs.args[4]).to.deep.equal([db.users, [ + { _id: '_design/:staged:users', views: { users1: {} }, deploy_info: deployInfo } + ]]); }); it('should delete eventual _rev properties', async () => { @@ -477,12 +494,13 @@ describe('Setup utils', () => { { _id: '_design/meta1', _rev: 100, views: { usersmeta1: {} } }, { _id: '_design/meta2', _rev: 582, views: { usersmeta2: {} } }, ]); + ddocDefinitions.set(DATABASES[4], [{ _id: '_design/users', views: { users1: {} } }]); sinon.stub(db, 'saveDocs').resolves(); await utils.saveStagedDdocs(ddocDefinitions); - expect(db.saveDocs.callCount).to.equal(4); + expect(db.saveDocs.callCount).to.equal(5); expect(db.saveDocs.args[0]).to.deep.equal([db.medic, [ { _id: '_design/:staged:medic', views: { medic: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:medic-client', views: { clienta: {}, clientb: {} }, deploy_info: deployInfo }, @@ -497,6 +515,9 @@ describe('Setup utils', () => { { _id: '_design/:staged:meta1', views: { usersmeta1: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:meta2', views: { usersmeta2: {} }, deploy_info: deployInfo }, ]]); + expect(db.saveDocs.args[4]).to.deep.equal([db.users, [ + { _id: '_design/:staged:users', views: { users1: {} }, deploy_info: deployInfo } + ]]); }); it('should work when db has been removed from upgrade', async () => { @@ -514,12 +535,13 @@ describe('Setup utils', () => { { _id: '_design/meta1', views: { usersmeta1: {} } }, { _id: '_design/meta2', views: { usersmeta2: {} } }, ]); + ddocDefinitions.set(DATABASES[4], [{ _id: '_design/users', views: { users1: {} } }]); sinon.stub(db, 'saveDocs').resolves(); await utils.saveStagedDdocs(ddocDefinitions); - expect(db.saveDocs.callCount).to.equal(4); + expect(db.saveDocs.callCount).to.equal(5); expect(db.saveDocs.args[0]).to.deep.equal([db.medic, [ { _id: '_design/:staged:medic', views: { medic: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:medic-client', views: { clienta: {}, clientb: {} }, deploy_info: deployInfo }, @@ -532,6 +554,9 @@ describe('Setup utils', () => { { _id: '_design/:staged:meta1', views: { usersmeta1: {} }, deploy_info: deployInfo }, { _id: '_design/:staged:meta2', views: { usersmeta2: {} }, deploy_info: deployInfo }, ]]); + expect(db.saveDocs.args[4]).to.deep.equal([db.users, [ + { _id: '_design/:staged:users', views: { users1: {} }, deploy_info: deployInfo } + ]]); }); it('should throw error if staging fails', async () => { @@ -678,6 +703,7 @@ describe('Setup utils', () => { { doc: { _id: '_design/logs1', _rev: '3', field: 'b', deploy_info: deployInfoOld } }, ] }); // one extra staged ddoc sinon.stub(db.medicUsersMeta, 'allDocs').resolves({ rows: [] }); // no ddocs + sinon.stub(db.users, 'allDocs').resolves({ rows: [] }); // no ddocs sinon.stub(db, 'saveDocs').resolves(); @@ -693,8 +719,10 @@ describe('Setup utils', () => { expect(db.medicLogs.allDocs.args[0]).to.deep.equal(allDocsArgs); expect(db.medicUsersMeta.allDocs.callCount).to.equal(1); expect(db.medicUsersMeta.allDocs.args[0]).to.deep.equal(allDocsArgs); + expect(db.users.allDocs.callCount).to.equal(1); + expect(db.users.allDocs.args[0]).to.deep.equal(allDocsArgs); - expect(db.saveDocs.callCount).to.equal(4); + expect(db.saveDocs.callCount).to.equal(5); expect(db.saveDocs.args[0]).to.deep.equal([db.medic, [ { _id: '_design/medic', _rev: '2', new: true, deploy_info: deployInfoExpected }, { _id: '_design/medic-client', _rev: '3', new: true, deploy_info: deployInfoExpected }, @@ -707,6 +735,7 @@ describe('Setup utils', () => { { _id: '_design/logs2', deploy_info: deployInfoExpected }, ]]); expect(db.saveDocs.args[3]).to.deep.equal([db.medicUsersMeta, []]); + expect(db.saveDocs.args[4]).to.deep.equal([db.users, []]); }); it('should throw an error when getting ddocs fails', async () => { diff --git a/ddocs/users-db/users/_id b/ddocs/users-db/users/_id new file mode 100644 index 00000000000..b07941f6f20 --- /dev/null +++ b/ddocs/users-db/users/_id @@ -0,0 +1 @@ +_design/users diff --git a/ddocs/users-db/users/views/users_by_field/map.js b/ddocs/users-db/users/views/users_by_field/map.js new file mode 100644 index 00000000000..a260317467c --- /dev/null +++ b/ddocs/users-db/users/views/users_by_field/map.js @@ -0,0 +1,8 @@ +function(doc) { + if (doc.contact_id) { + emit(['contact_id', doc.contact_id]); + } + if (doc.facility_id) { + emit(['facility_id', doc.facility_id]); + } +} diff --git a/scripts/build/ddoc-compile.js b/scripts/build/ddoc-compile.js index a03acccd252..671905b9aa5 100644 --- a/scripts/build/ddoc-compile.js +++ b/scripts/build/ddoc-compile.js @@ -17,6 +17,7 @@ const compilePrimary = async () => { await compile([ 'build/ddocs/sentinel-db/sentinel' ], 'build/ddocs/sentinel.json'); await compile([ 'build/ddocs/users-meta-db/users-meta' ], 'build/ddocs/users-meta.json'); await compile([ 'build/ddocs/logs-db/logs' ], 'build/ddocs/logs.json'); + await compile([ 'build/ddocs/users-db/users' ], 'build/ddocs/users.json'); }; const commands = { diff --git a/shared-libs/user-management/src/libs/facility.js b/shared-libs/user-management/src/libs/facility.js index cac75ce6ae5..29e4ab243c0 100644 --- a/shared-libs/user-management/src/libs/facility.js +++ b/shared-libs/user-management/src/libs/facility.js @@ -1,12 +1,10 @@ const db = require('./db'); -const list = async (users, settings) => { +const list = async (users) => { const ids = new Set(); for (const user of users) { - ids.add(user?.doc?.facility_id); - } - for (const setting of settings) { - ids.add(setting?.contact_id); + ids.add(user?.facility_id); + ids.add(user?.contact_id); } ids.delete(undefined); if (!ids.size) { diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 0bfbafb4c2c..893eb777d8e 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -45,6 +45,7 @@ const RESTRICTED_USER_EDITABLE_FIELDS = [ const USER_EDITABLE_FIELDS = RESTRICTED_USER_EDITABLE_FIELDS.concat([ 'place', + 'contact', 'type', 'roles', ]); @@ -105,18 +106,44 @@ const getDocID = doc => { } }; -const getAllUserSettings = () => { - const opts = { - include_docs: true, - key: ['user-settings'] - }; - return db.medic.query('medic-client/doc_by_type', opts) - .then(result => result.rows.map(row => row.doc)); +const queryDocs = (db, view, key) => db + .query(view, { include_docs: true, key }) + .then(({ rows }) => rows.map(({ doc }) => doc)); + +const getAllUserSettings = () => queryDocs(db.medic, 'medic-client/doc_by_type', ['user-settings']); + +const getSettingsByIds = async (ids) => { + const { rows } = await db.medic.allDocs({ keys: ids, include_docs: true }); + return rows + .map(row => row.doc) + .filter(Boolean); }; -const getAllUsers = () => { - return db.users.allDocs({ include_docs: true }) - .then(result => result.rows); +const getAllUsers = async () => db.users + .allDocs({ include_docs: true, start_key: 'org.couchdb.user:', end_key: 'org.couchdb.user:\ufff0' }) + .then(({ rows }) => rows.map(({ doc }) => doc)); + +const getUsers = async (facilityId, contactId) => { + if (!contactId) { + return queryDocs(db.users, 'users/users_by_field', ['facility_id', facilityId]); + } + + const usersForContactId = await queryDocs(db.users, 'users/users_by_field', ['contact_id', contactId]); + if (!facilityId) { + return usersForContactId; + } + + return usersForContactId.filter(user => user.facility_id === facilityId); +}; + +const getUsersAndSettings = async ({ facilityId, contactId } = {}) => { + if (!facilityId && !contactId) { + return Promise.all([getAllUsers(), getAllUserSettings()]); + } + const users = await getUsers(facilityId, contactId); + const ids = users.map(({ _id }) => _id); + const settings = await getSettingsByIds(ids); + return [users, settings]; }; const validateContact = (id, placeID) => { @@ -335,21 +362,21 @@ const hasParent = (facility, id) => { const mapUsers = (users, settings, facilities) => { users = users || []; return users - .filter(user => user.id.indexOf(USER_PREFIX) === 0) + .filter(user => user._id.indexOf(USER_PREFIX) === 0) .map(user => { - const setting = getDoc(user.id, settings) || {}; + const setting = getDoc(user._id, settings) || {}; return { - id: user.id, - rev: user.doc._rev, - username: user.doc.name, + id: user._id, + rev: user._rev, + username: user.name, fullname: setting.fullname, email: setting.email, phone: setting.phone, - place: getDoc(user.doc.facility_id, facilities), - roles: user.doc.roles, - contact: getDoc(setting.contact_id, facilities), + place: getDoc(user.facility_id, facilities), + roles: user.roles, + contact: getDoc(user.contact_id, facilities), external_id: setting.external_id, - known: user.doc.known + known: user.known }; }); }; @@ -395,7 +422,7 @@ const getSettingsUpdates = (username, data) => { }; const getUserUpdates = (username, data) => { - const ignore = ['type', 'place']; + const ignore = ['type', 'place', 'contact']; const user = { name: username, @@ -418,6 +445,9 @@ const getUserUpdates = (username, data) => { if (data.place) { user.facility_id = getDocID(data.place); } + if (data.contact) { + user.contact_id = getDocID(data.contact); + } return user; }; @@ -544,7 +574,7 @@ const validateUserFacility = (data, user, userSettings) => { const validateUserContact = (data, user, userSettings) => { if (data.contact) { - return validateContact(userSettings.contact_id, user.facility_id); + return validateContact(user.contact_id, user.facility_id); } if (_.isNull(data.contact)) { @@ -555,6 +585,7 @@ const validateUserContact = (data, user, userSettings) => { {'field': 'Contact'} )); } + user.contact_id = null; userSettings.contact_id = null; } }; @@ -760,7 +791,7 @@ const getUserDocsByName = (name) => { const getUserSettings = async({ name }) => { const [ user, medicUser ] = await getUserDocsByName(name); - Object.assign(medicUser, _.pick(user, 'name', 'roles', 'facility_id')); + Object.assign(medicUser, _.pick(user, 'name', 'roles', 'facility_id', 'contact_id')); return hydrateUserSettings(medicUser); }; @@ -770,9 +801,9 @@ const getUserSettings = async({ name }) => { */ module.exports = { deleteUser: username => deleteUser(createID(username)), - getList: async () => { - const [ users, settings ] = await Promise.all([ getAllUsers(), getAllUserSettings() ]); - const facilities = await facility.list(users, settings); + getList: async (filters) => { + const [users, settings] = await getUsersAndSettings(filters); + const facilities = await facility.list(users); return mapUsers(users, settings, facilities); }, getUserSettings, diff --git a/shared-libs/user-management/test/unit/libs/facility.spec.js b/shared-libs/user-management/test/unit/libs/facility.spec.js index 68abde21ab2..6c9b382748b 100644 --- a/shared-libs/user-management/test/unit/libs/facility.spec.js +++ b/shared-libs/user-management/test/unit/libs/facility.spec.js @@ -6,11 +6,8 @@ const db = require('../../../src/libs/db'); describe('facility', () => { - const userA = { doc: { facility_id: 'a' } }; - const userB = { doc: { facility_id: 'b' } }; - - const settingA = { contact_id: 'a' }; - const settingB = { contact_id: 'e' }; + const userA = { facility_id: 'a', contact_id: 'a' }; + const userB = { facility_id: 'b', contact_id: 'e' }; const facilityA = { _id: 'a' }; const facilityB = { _id: 'b' }; @@ -41,7 +38,7 @@ describe('facility', () => { { doc: facilityB }, facilityNotFound, ] }); - const result = await list([ userA, userB ], [ settingA, settingB ]); + const result = await list([userA, userB]); expect(result).to.deep.equal([ facilityA, facilityB ]); expect(allDocs.callCount).to.equal(1); expect(allDocs.args[0][0].keys).to.deep.equal([ 'a', 'b', 'e' ]); diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index c041adca326..f6a0beb3cc8 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -16,6 +16,11 @@ const COMPLEX_PASSWORD = '23l4ijk3nSDELKSFnwekirh'; const facilitya = { _id: 'a', name: 'aaron' }; const facilityb = { _id: 'b', name: 'brian' }; const facilityc = { _id: 'c', name: 'cathy' }; +const contactMilan = { + _id: 'milan-contact', + type: 'person', + name: 'milan', +}; let userData; let clock; @@ -31,9 +36,14 @@ describe('Users service', () => { getTransitionsLib: sinon.stub(), }); db.init({ - medic: { get: sinon.stub(), put: sinon.stub(), allDocs: sinon.stub(), query: sinon.stub() }, + medic: { + get: sinon.stub(), + put: sinon.stub(), + allDocs: sinon.stub(), + query: sinon.stub(), + }, medicLogs: { get: sinon.stub(), put: sinon.stub(), }, - users: { get: sinon.stub(), put: sinon.stub() }, + users: { query: sinon.stub(), get: sinon.stub(), put: sinon.stub() }, }); lineage.init(require('@medic/lineage')(Promise, db.medic)); addMessage = sinon.stub(); @@ -43,6 +53,7 @@ describe('Users service', () => { facilitya, facilityb, facilityc, + contactMilan, ]); sinon.stub(couchSettings, 'getCouchConfig').resolves(); userData = { @@ -122,13 +133,16 @@ describe('Users service', () => { chai.expect(user.name ).to.equal('john'); }); - it('reassigns place field', () => { + it('reassigns place and contact fields', () => { const data = { - place: 'abc' + place: 'abc', + contact: 'xyz' }; const user = service.__get__('getUserUpdates')('john', data); chai.expect(user.place).to.equal(undefined); + chai.expect(user.contact).to.equal(undefined); chai.expect(user.facility_id).to.equal('abc'); + chai.expect(user.contact_id).to.equal('xyz'); }); }); @@ -189,48 +203,197 @@ describe('Users service', () => { }); describe('getList', () => { + describe('with filters', () => { + it('with facility_id', async () => { + const filters = { facilityId: 'c' }; + const usersResponse = { + rows: [{ + doc: { + _id: 'org.couchdb.user:x', + name: 'lucas', + facility_id: 'c', + roles: ['national-admin', 'data-entry'], + } + }], + }; + db.users.query.withArgs('users/users_by_field', { + include_docs: true, + key: ['facility_id', filters.facilityId], + }).resolves(usersResponse); + + const userSettingsResponse = { + rows: [{ + doc: { + _id: 'org.couchdb.user:x', + name: 'lucas', + fullname: 'Lucas M', + email: 'l@m.com', + phone: '123456789', + facility_id: 'c', + }, + }], + }; + db.medic.allDocs.withArgs({ keys: ['org.couchdb.user:x'], include_docs: true }).resolves(userSettingsResponse); - it('collects user infos', () => { - const allUsers = [ - { - id: 'org.couchdb.user:x', - doc: { + const data = await service.getList(filters); + chai.expect(data.length).to.equal(1); + const lucas = data[0]; + chai.expect(lucas.id).to.equal('org.couchdb.user:x'); + chai.expect(lucas.username).to.equal('lucas'); + chai.expect(lucas.fullname).to.equal('Lucas M'); + chai.expect(lucas.email).to.equal('l@m.com'); + chai.expect(lucas.phone).to.equal('123456789'); + chai.expect(lucas.place).to.deep.equal(facilityc); + chai.expect(lucas.roles).to.deep.equal(['national-admin', 'data-entry']); + }); + + it('with contact_id', async () => { + const filters = { contactId: 'milan-contact' }; + const usersResponse = { + rows: [{ + doc: { + _id: 'org.couchdb.user:y', + name: 'milan', + facility_id: 'b', + contact_id: 'milan-contact', + roles: ['district-admin'], + } + }], + }; + db.users.query.withArgs('users/users_by_field', { + include_docs: true, + key: ['contact_id', filters.contactId], + }).resolves(usersResponse); + + const userSettingsResponse = { + rows: [{ + doc: { + _id: 'org.couchdb.user:y', + name: 'milan', + fullname: 'Milan A', + email: 'm@a.com', + phone: '987654321', + external_id: 'LTT093', + facility_id: 'b', + contact_id: 'milan-contact', + }, + }], + }; + db.medic.allDocs.withArgs({ keys: ['org.couchdb.user:y'], include_docs: true }).resolves(userSettingsResponse); + + const data = await service.getList(filters); + chai.expect(data.length).to.equal(1); + const milan = data[0]; + chai.expect(milan.id).to.equal('org.couchdb.user:y'); + chai.expect(milan.username).to.equal('milan'); + chai.expect(milan.fullname).to.equal('Milan A'); + chai.expect(milan.email).to.equal('m@a.com'); + chai.expect(milan.phone).to.equal('987654321'); + chai.expect(milan.contact._id).to.equal('milan-contact'); + chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.roles).to.deep.equal(['district-admin']); + }); + + it('with both facility_id and contact_id', async () => { + const filters = { facilityId: 'b', contactId: 'milan-contact' }; + const usersResponse = { + rows: [ + { + doc: { + _id: 'org.couchdb.user:y', + name: 'milan', + facility_id: 'b', + contact_id: 'milan-contact', + roles: ['district-admin'], + } + }, + { + doc: { + _id: 'org.couchdb.user:z', + name: 'bill', + facility_id: 'a', + contact_id: 'milan-contact', + roles: ['district-admin'], + } + } + ], + }; + db.users.query.withArgs('users/users_by_field', { + include_docs: true, + key: ['contact_id', filters.contactId], + }).resolves(usersResponse); + + const userSettingsResponse = { + rows: [{ + doc: { + _id: 'org.couchdb.user:y', + name: 'milan', + fullname: 'Milan A', + email: 'm@a.com', + phone: '987654321', + external_id: 'LTT093', + facility_id: 'b', + contact_id: 'milan-contact', + }, + }], + }; + db.medic.allDocs.withArgs({ keys: ['org.couchdb.user:y'], include_docs: true }).resolves(userSettingsResponse); + + const data = await service.getList(filters); + chai.expect(db.users.query.callCount).to.equal(1); + chai.expect(data.length).to.equal(1); + const milan = data[0]; + chai.expect(milan.id).to.equal('org.couchdb.user:y'); + chai.expect(milan.username).to.equal('milan'); + chai.expect(milan.fullname).to.equal('Milan A'); + chai.expect(milan.email).to.equal('m@a.com'); + chai.expect(milan.phone).to.equal('987654321'); + chai.expect(milan.contact._id).to.equal('milan-contact'); + chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.roles).to.deep.equal(['district-admin']); + }); + }); + + describe('without filters', () => { + it('collects user infos', async () => { + const allUsers = [ + { + _id: 'org.couchdb.user:x', name: 'lucas', facility_id: 'c', - roles: [ 'national-admin', 'data-entry' ] + roles: ['national-admin', 'data-entry'] + }, + { + _id: 'org.couchdb.user:y', + name: 'milan', + facility_id: 'b', + roles: ['district-admin'] } - }, - { - id: 'org.couchdb.user:y', - doc: { + ]; + const allUsersSettings = [ + { + _id: 'org.couchdb.user:x', + name: 'lucas', + fullname: 'Lucas M', + email: 'l@m.com', + phone: '123456789', + facility_id: 'c', + }, + { + _id: 'org.couchdb.user:y', name: 'milan', + fullname: 'Milan A', + email: 'm@a.com', + phone: '987654321', + external_id: 'LTT093', facility_id: 'b', - roles: [ 'district-admin' ] } - } - ]; - const allUsersSettings = [ - { - _id: 'org.couchdb.user:x', - name: 'lucas', - fullname: 'Lucas M', - email: 'l@m.com', - phone: '123456789' - }, - { - _id: 'org.couchdb.user:y', - name: 'milan', - fullname: 'Milan A', - email: 'm@a.com', - phone: '987654321', - external_id: 'LTT093' - } - ]; - service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); - service.__set__('getAllUserSettings', sinon.stub().resolves(allUsersSettings)); - return service.getList().then(data => { + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves(allUsersSettings)); + const data = await service.getList(); chai.expect(data.length).to.equal(2); - const lucas = data[0]; + const [lucas, milan] = data; chai.expect(lucas.id).to.equal('org.couchdb.user:x'); chai.expect(lucas.username).to.equal('lucas'); chai.expect(lucas.fullname).to.equal('Lucas M'); @@ -238,7 +401,6 @@ describe('Users service', () => { chai.expect(lucas.phone).to.equal('123456789'); chai.expect(lucas.place).to.deep.equal(facilityc); chai.expect(lucas.roles).to.deep.equal([ 'national-admin', 'data-entry' ]); - const milan = data[1]; chai.expect(milan.id).to.equal('org.couchdb.user:y'); chai.expect(milan.username).to.equal('milan'); chai.expect(milan.fullname).to.equal('Milan A'); @@ -248,53 +410,50 @@ describe('Users service', () => { chai.expect(milan.roles).to.deep.equal([ 'district-admin' ]); chai.expect(milan.external_id).to.equal('LTT093'); }); - }); - it('filters out non-users', () => { - const allUsers = [ - { - id: 'x', - doc: { + it('filters out non-users', async () => { + const allUsers = [ + { + _id: 'x', name: 'lucas', facility_id: 'c', fullname: 'Lucas M', email: 'l@m.com', phone: '123456789', - roles: [ 'national-admin', 'data-entry' ] - } - }, - { - id: 'org.couchdb.user:y', - doc: { + roles: ['national-admin', 'data-entry'] + }, + { + _id: 'org.couchdb.user:y', name: 'milan', facility_id: 'b', fullname: 'Milan A', email: 'm@a.com', phone: '987654321', - roles: [ 'district-admin' ] + roles: ['district-admin'] } - } - ]; - const allUserSettings = [ - { - _id: 'org.couchdb.user:x', - name: 'lucas', - fullname: 'Lucas M', - email: 'l@m.com', - phone: '123456789' - }, - { - _id: 'org.couchdb.user:y', - name: 'milan', - fullname: 'Milan A', - email: 'm@a.com', - phone: '987654321' - } - ]; - service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); - service.__set__('getAllUserSettings', sinon.stub().resolves(allUserSettings)); + ]; + const allUserSettings = [ + { + _id: 'org.couchdb.user:x', + name: 'lucas', + fullname: 'Lucas M', + email: 'l@m.com', + phone: '123456789', + facility_id: 'c', + }, + { + _id: 'org.couchdb.user:y', + name: 'milan', + fullname: 'Milan A', + email: 'm@a.com', + phone: '987654321', + facility_id: 'b', + } + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves(allUserSettings)); - return service.getList().then(data => { + const data = await service.getList(); chai.expect(data.length).to.equal(1); const milan = data[0]; chai.expect(milan.id).to.equal('org.couchdb.user:y'); @@ -306,20 +465,16 @@ describe('Users service', () => { chai.expect(milan.roles).to.deep.equal([ 'district-admin' ]); }); - }); - - it('handles minimal users', () => { - const allUsers = [ - { - id: 'org.couchdb.user:x', - doc: { + it('handles minimal users', async () => { + const allUsers = [ + { + _id: 'org.couchdb.user:x', name: 'lucas' } - } - ]; - service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); - service.__set__('getAllUserSettings', sinon.stub().resolves([])); - return service.getList().then(data => { + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves([])); + const data = await service.getList(); chai.expect(data.length).to.equal(1); const lucas = data[0]; chai.expect(lucas.id).to.equal('org.couchdb.user:x'); @@ -330,32 +485,28 @@ describe('Users service', () => { chai.expect(lucas.facility).to.equal(undefined); chai.expect(lucas.roles).to.equal(undefined); }); - }); - it('returns errors from users service', () => { - service.__set__('getAllUsers', sinon.stub().rejects('not found')); - service.__set__('getAllUserSettings', sinon.stub().rejects('not found')); - return service.getList().catch(err => { - chai.expect(err.name).to.equal('not found'); + it('returns errors from users service', () => { + service.__set__('getAllUsers', sinon.stub().rejects('not found')); + service.__set__('getAllUserSettings', sinon.stub().rejects('not found')); + return service.getList().catch(err => { + chai.expect(err.name).to.equal('not found'); + }); }); - }); - it('returns errors from facilities service', () => { - const allUsers = [ - { - id: 'x', - doc: { + it('returns errors from facilities service', () => { + const allUsers = [ + { + _id: 'x', name: 'lucas', facility_id: 'c', fullname: 'Lucas M', email: 'l@m.com', phone: '123456789', roles: [ 'national-admin', 'data-entry' ] - } - }, - { - id: 'org.couchdb.user:y', - doc: { + }, + { + _id: 'org.couchdb.user:y', name: 'milan', facility_id: 'b', fullname: 'Milan A', @@ -363,23 +514,22 @@ describe('Users service', () => { phone: '987654321', roles: [ 'district-admin' ] } - } - ]; - service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); - service.__set__('getAllUserSettings', sinon.stub().resolves([])); - service.__set__('getFacilities', sinon.stub().rejects('BOOM')); - return service.getList().catch(err => { - chai.expect(err.name).to.equal('BOOM'); + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves([])); + service.__set__('getFacilities', sinon.stub().rejects('BOOM')); + return service.getList().catch(err => { + chai.expect(err.name).to.equal('BOOM'); + }); }); }); - }); describe('getUserSettings', () => { it('returns medic user doc with facility from couchdb user doc', () => { - db.users.get.resolves({ name: 'steve', facility_id: 'steveVille', roles: ['b'] }); - db.medic.get.resolves({ name: 'steve2', facility_id: 'otherville', contact_id: 'steve', roles: ['c'] }); + db.users.get.resolves({ name: 'steve', facility_id: 'steveVille', contact_id: 'steve', roles: ['b'] }); + db.medic.get.resolves({ name: 'steve2', facility_id: 'otherville', contact_id: 'not_steve', roles: ['c'] }); db.medic.allDocs.resolves({ rows: [ { id: 'steveVille', key: 'steveVille', doc: { _id: 'steveVille', place_id: 'steve_ville', name: 'steve V' } }, @@ -2247,6 +2397,7 @@ describe('Users service', () => { }; service.__set__('validateUser', sinon.stub().resolves({ facility_id: 'maine', + contact_id: 1, roles: ['mm-online'] })); service.__set__('validateUserSettings', sinon.stub().resolves({ @@ -2264,6 +2415,7 @@ describe('Users service', () => { chai.expect(db.users.put.callCount).to.equal(1); const user = db.users.put.args[0][0]; chai.expect(user.facility_id).to.equal(null); + chai.expect(user.contact_id).to.equal(null); }); }); diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index a131da5f9fe..2749de1e2bd 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -66,6 +66,7 @@ describe('Users API', () => { name: username, password: password, facility_id: null, + contact_id: null, roles: [ 'chw', 'data_entry', @@ -73,6 +74,7 @@ describe('Users API', () => { }; const newPlaceId = 'NewPlaceId' + new Date().getTime(); + const newContactId = 'NewContactId' + new Date().getTime(); let cookie; @@ -92,7 +94,14 @@ describe('Users API', () => { { _id: newPlaceId, type: 'clinic' - } + }, + { + _id: newContactId, + type: 'person', + parent: { + _id: newPlaceId, + }, + }, ]; before(async () => { @@ -165,19 +174,28 @@ describe('Users API', () => { await utils.revertDb([], true); }); - it('Allows for admin users to modify someone', () => { - return utils - .request({ - path: `/api/v1/users/${username}`, - method: 'POST', - body: { - place: newPlaceId - } - }) - .then(() => utils.getDoc(getUserId(username))) - .then(doc => { - chai.expect(doc.facility_id).to.equal(newPlaceId); - }); + it('Allows for admin users to modify someone', async () => { + let userSettingsDoc = await utils.getDoc(getUserId(username)); + chai.expect(userSettingsDoc.facility_id).to.equal(null); + chai.expect(userSettingsDoc.contact_id).to.equal(null); + let userDoc = await utils.usersDb.get(getUserId(username)); + chai.expect(userDoc.facility_id).to.equal(null); + chai.expect(userDoc.contact_id).to.equal(null); + + await utils.request({ + path: `/api/v1/users/${username}`, + method: 'POST', + body: { + place: newPlaceId, + contact: newContactId, + }, + }); + userSettingsDoc = await utils.getDoc(getUserId(username)); + chai.expect(userSettingsDoc.facility_id).to.equal(newPlaceId); + chai.expect(userSettingsDoc.contact_id).to.equal(newContactId); + userDoc = await utils.usersDb.get(getUserId(username)); + chai.expect(userDoc.facility_id).to.equal(newPlaceId); + chai.expect(userDoc.contact_id).to.equal(newContactId); }); it('401s if a user without the right permissions attempts to modify someone else', () => { @@ -623,6 +641,7 @@ describe('Users API', () => { type: 'user', roles: ['district_admin'], facility_id: 'fixture:test', + contact_id: 'fixture:user:testuser', }; chai.expect(user).to.shallowDeepEqual(Object.assign(defaultProps, extra)); }; @@ -1015,7 +1034,12 @@ describe('Users API', () => { for (const user of users) { let [userInDb, userSettings] = await Promise.all([getUser(user), getUserSettings(user)]); - const extraProps = { facility_id: user.place._id, name: user.username, roles: user.roles }; + const extraProps = { + facility_id: user.place._id, + contact_id: user.contact._id, + name: user.username, + roles: user.roles, + }; expectCorrectUser(userInDb, extraProps); expectCorrectUserSettings(userSettings, { ...extraProps, contact_id: user.contact._id }); chai.expect(userInDb.token_login).to.be.undefined; @@ -1125,7 +1149,12 @@ describe('Users API', () => { for (const user of users) { let [userInDb, userSettings] = await Promise.all([getUser(user), getUserSettings(user)]); - const extraProps = { facility_id: user.place._id, name: user.username, roles: user.roles }; + const extraProps = { + facility_id: user.place._id, + contact_id: user.contact._id, + name: user.username, + roles: user.roles, + }; expectCorrectUser(userInDb, extraProps); expectCorrectUserSettings(userSettings, { ...extraProps, contact_id: user.contact._id }); chai.expect(userInDb.token_login).to.be.ok; @@ -1585,7 +1614,7 @@ describe('Users API', () => { await utils.revertDb([], true); }); - it('should create and get users', async () => { + it('should create and get all users', async () => { const users = Array.from({ length: 10 }).map(() => ({ username: uuid(), password: password, @@ -1600,10 +1629,7 @@ describe('Users API', () => { roles: ['district_admin', 'mm-online'] })); - const createUserOpts = { path: '/api/v2/users', method: 'POST' }; - for (const user of users) { - await utils.request({ ...createUserOpts, body: user }); - } + await utils.request({ path: '/api/v2/users', method: 'POST', body: users }); const savedUsers = await utils.request({ path: '/api/v2/users' }); for (const user of users) { @@ -1617,5 +1643,150 @@ describe('Users API', () => { }); } }); + + it('should create and query users using filters', async () => { + const facilityE = await utils.request({ + path: '/api/v1/places', + method: 'POST', + body: { type: 'health_center', name: 'Facility E', parent: 'PARENT_PLACE' }, + }); + const facilityF = await utils.request({ + path: '/api/v1/places', + method: 'POST', + body: { type: 'health_center', name: 'Facility F', parent: 'PARENT_PLACE' }, + }); + const contactA = await utils.request({ + path: '/api/v1/people', + method: 'POST', + body: { name: 'Contact A', place: facilityE.id }, + }); + const contactB = await utils.request({ + path: '/api/v1/people', + method: 'POST', + body: { name: 'Contact B', place: facilityE.id }, + }); + const contactC = await utils.request({ + path: '/api/v1/people', + method: 'POST', + body: { name: 'Contact C', place: facilityF.id }, + }); + + const userFactory = ({ contact, place }) => ({ + username: uuid(), + password: password, + roles: ['district_admin', 'mm-online'], + contact, + place, + }); + const user1 = userFactory({ contact: contactA.id, place: facilityE.id }); + const user2 = userFactory({ contact: contactA.id, place: facilityE.id }); + const user3 = userFactory({ contact: contactB.id, place: facilityE.id }); + const user4 = userFactory({ contact: contactC.id, place: facilityF.id }); + const user5 = userFactory({ contact: contactC.id, place: facilityF.id }); + const [user1Response, user2Response, user3Response, user4Response, user5Response] = await utils.request({ + path: '/api/v2/users', + method: 'POST', + body: [user1, user2, user3, user4, user5], + }); + + const user5Name = user5Response.user.id.replace('org.couchdb.user:', ''); + await utils.request({ + path: `/api/v1/users/${user5Name}`, + method: 'DELETE', + }); + + let filteredUsers; + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { + facility_id: facilityE.id, + contact_id: contactA.id, + }, + }); + expect(filteredUsers.length).to.equal(2); + // using find instead of accessing array by index here because + // couch sorts results by their id, not by their creation order + expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ + id: user1Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ + id: user2Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { facility_id: facilityE.id }, + }); + expect(filteredUsers.length).to.equal(3); + expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ + id: user1Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ + id: user2Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + expect(filteredUsers.find(user => user.id === user3Response.user.id)).to.deep.nested.include({ + id: user3Response.user.id, + 'contact._id': contactB.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { contact_id: contactA.id }, + }); + expect(filteredUsers.length).to.equal(2); + expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ + id: user1Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ + id: user2Response.user.id, + 'contact._id': contactA.id, + 'place._id': facilityE.id, + 'place.parent._id': parentPlace._id, + }); + + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { contact_id: contactC.id }, + }); + expect(filteredUsers.length).to.equal(1); + expect(filteredUsers.find(user => user.id === user4Response.user.id)).to.deep.nested.include({ + id: user4Response.user.id, + 'contact._id': contactC.id, + 'place._id': facilityF.id, + 'place.parent._id': parentPlace._id, + }); + + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { contact_id: 'non_existent_contact' }, + }); + expect(filteredUsers.length).to.equal(0); + + filteredUsers = await utils.request({ + path: '/api/v2/users', + qs: { facility_id: 'non_existent_facility' }, + }); + expect(filteredUsers.length).to.equal(0); + + const allUsers = await utils.request({ path: '/api/v2/users' }); + expect(allUsers.map(user => user.id)).to.not.include(user5Response.user.id); + }); }); });