Skip to content

Commit

Permalink
feat(#8877): Look up users from their facility_id or contact_id (#…
Browse files Browse the repository at this point in the history
…8928)

Co-authored-by: Joshua Kuestersteffen <[email protected]>
Co-authored-by: Diana Barsan <[email protected]>
  • Loading branch information
3 people authored Apr 18, 2024
1 parent 61cf2ba commit 5e9032d
Show file tree
Hide file tree
Showing 17 changed files with 1,003 additions and 187 deletions.
8 changes: 6 additions & 2 deletions api/src/controllers/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions api/src/migrations/add-contact-id-to-user-docs.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 5 additions & 0 deletions api/src/services/setup/databases.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
115 changes: 115 additions & 0 deletions api/tests/integration/migrations/add-contact-id-to-user-docs.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 5 additions & 0 deletions api/tests/integration/migrations/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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 => {
Expand Down
75 changes: 68 additions & 7 deletions api/tests/mocha/controllers/users.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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');
});
});

});
Expand Down
Loading

0 comments on commit 5e9032d

Please sign in to comment.