Skip to content

Commit

Permalink
Bulk import to create or reassign CHAs with multiple CHUs (#244)
Browse files Browse the repository at this point in the history
* Unit tests passing for reassignment

* Refactor to single "import-cha-users" script

* Update place contacts

* Round of testing
  • Loading branch information
kennsippell authored Jan 31, 2025
1 parent eaeba1a commit 0d3fa05
Show file tree
Hide file tree
Showing 20 changed files with 690 additions and 252 deletions.
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"rewire": "^7.0.0",
"sinon": "^17.0.1",
"ts-mocha": "^10.0.0",
"tsc-watch": "^6.0.4"
"tsc-watch": "^6.0.4",
"yesno": "^0.4.0"
},
"scripts": {
"cp-package-json": "cp package.json ./src",
Expand Down
4 changes: 0 additions & 4 deletions scripts/create-cha-users/Test CHAs.csv

This file was deleted.

6 changes: 6 additions & 0 deletions scripts/import-cha-users/Import CHAs.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Sub County,Affected CHU,CHA Name,CHA Phone
Digital Payment,Hierarchy,CHA Name,0712344321
Digital Payment,Kenn,CHA Name,0712344321
Digital Payment,Replaced Holiday,CHA Name,0712344321
Narok East,Empaash,CHA Name,0712344321
Digital Payment,Kenn,Hierarchy Cha,0712344321
44 changes: 44 additions & 0 deletions scripts/import-cha-users/cha-reassignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import _ from 'lodash';
import { ChtApi } from '../../src/lib/cht-api';
import Place from '../../src/services/place';
import { MultiplaceUsers, PlaceReassignment } from '../../src/lib/multiplace-users';
import PrimaryContactDirectory from './primary-contact-directory';

export default class ChaReassignment {
private readonly chtApi: ChtApi;

constructor(chtApi: ChtApi) {
this.chtApi = chtApi;
}

public async reassignUsersFromPlaces(places: Place[], usernames: PrimaryContactDirectory) {
const reassignmentPromises = places.map(place => this.toReassignments(place, usernames));
const reassignments = await Promise.all(reassignmentPromises);
const filteredReassignments = _.flatten(reassignments).filter(Boolean) as PlaceReassignment[];

await MultiplaceUsers.reassignPlaces(filteredReassignments, this.chtApi);
}

private async toReassignments(place: Place, usernames: PrimaryContactDirectory): Promise<PlaceReassignment[] | undefined> {
const placeName = place.resolvedHierarchy[0]?.name.formatted;
const placeId = place.resolvedHierarchy[0]?.id;
const userInfos = await usernames.getUsersAtPlaceWithPC(place);

if (!placeId) {
throw Error('no placeId');
}

if (!userInfos?.length) {
throw Error(`Cannot reassign. No username for ${placeName}`);
}

console.log(`Reassigning: "${placeName}" (${placeId}) to user "${userInfos.map(info => info.username)}"`);
return userInfos
.map(userInfo => ({
placeId,
toUsername: userInfo.username,
deactivate: false,
}));
}
}

74 changes: 74 additions & 0 deletions scripts/import-cha-users/create-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import _ from 'lodash';
import yesno from 'yesno';

import { ChtApi } from '../../src/lib/cht-api';
import Place from '../../src/services/place';
import { UserPayload } from '../../src/services/user-payload';

export type CreatedUser = {
subcounty: string;
name: string;
username: string;
password: string;
};

export default async function createMultiplaceUsers(places: Place[], chtApi: ChtApi): Promise<CreatedUser[]> {
const chusByCha = _.groupBy(places, place => groupByKey(place));
const chaKeys = Object.keys(chusByCha);
if (!chaKeys.length) {
return [];
}

await promptToCreate(chaKeys);

const results: CreatedUser[] = [];
for (const chaKey of chaKeys) {
const chus = chusByCha[chaKey];
const created = await createCha(chus, chtApi);
results.push(created);
}

console.log(`Created users:`);
console.table(results);
return results;
}

function groupByKey(place: Place) {
const subcounty = place.resolvedHierarchy[1]?.name.formatted;
const contactName = place.contact.name;
return `${contactName}@${subcounty}`;
}

async function createCha(chus: Place[], chtApi: ChtApi): Promise<CreatedUser> {
const chaName = chus[0].contact.name;
const chuNames = chus.map(chu => chu.resolvedHierarchy[0]?.name.formatted);
console.log(`Creating CHA: ${chaName} with ${chus.length} CHUs: ${chuNames}`);

const contactDocId = chus.find(chu => chu.resolvedHierarchy[0]?.contactId)?.resolvedHierarchy[0]?.contactId;
if (!contactDocId) {
throw Error(`Unresolved contact id`);
}

const chuIds = chus.map(chu => chu.resolvedHierarchy[0]?.id).filter(Boolean) as string[];
const userPayload = new UserPayload(chus[0], chuIds, contactDocId);
const payload = await userPayload.create(chtApi);
console.log(`Username: ${payload.username} Password: ${payload.password}`);

return {
subcounty: chus[0]?.resolvedHierarchy[1]?.name.formatted || '?',
name: chaName,
username: payload.username,
password: payload.password,
};
}

async function promptToCreate(chaNames: string[]) {
const proceed = await yesno({
question: `Create ${chaNames.length} new users: ${chaNames}?`
});
if (!proceed) {
console.error('Exiting...');
process.exit(-1);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import ChtSession from '../../src/lib/cht-session';
import Place from '../../src/services/place';
import PlaceFactory from '../../src/services/place-factory';
import SessionCache from '../../src/services/session-cache';
import { UserPayload } from '../../src/services/user-payload';
import { UploadManager } from '../../src/services/upload-manager';

import PrimaryContactDirectory from './primary-contact-directory';
import createMultiplaceUsers from './create-user';
import ChaReassignment from './cha-reassignment';

const authInfo = {
friendly: 'Local Dev',
Expand All @@ -16,7 +20,7 @@ const authInfo = {
};
const username = 'medic';
const password = 'password';
const csvFilePath = './Test CHAs.csv';
const csvFilePath = './Import CHAs.csv';

(async function() {
const session = await ChtSession.create(authInfo, username, password);
Expand All @@ -25,28 +29,20 @@ const csvFilePath = './Test CHAs.csv';

assertAllPlacesValid(places);

const chusByCha = _.groupBy(places, 'contact.name');
const chaNames = Object.keys(chusByCha);
const usernames = await PrimaryContactDirectory.construct(chtApi);

const {
false: placesNeedingNewUser = [],
true: placesNeedingReassign = []
} = _.groupBy(places, place => usernames.primaryContactExists(place));

const results: any[] = [];
for (const chaName of chaNames) {
const chus = chusByCha[chaName];
const chuNames = chus.map(chu => chu.resolvedHierarchy[0]?.name.formatted);
console.log(`CHA: ${chaName} has ${chus.length} CHUs: ${chuNames}`);

const contactDocId = chus.find(chu => chu.resolvedHierarchy[0]?.contactId)?.resolvedHierarchy[0]?.contactId;
if (!contactDocId) {
throw Error(`Unresolved contact id`);
}

const chuIds = chus.map(chu => chu.resolvedHierarchy[0]?.id).filter(Boolean) as string[];
const userPayload = new UserPayload(chus[0], chuIds, contactDocId);
const result = await userPayload.create(chtApi);
console.log(`Username: ${result.username} Password: ${result.password}`);
results.push(_.pick(result, ['fullname', 'username', 'password']));
}
await createMultiplaceUsers(placesNeedingNewUser, chtApi);

const chaReassignment = new ChaReassignment(chtApi);
await chaReassignment.reassignUsersFromPlaces(placesNeedingReassign, usernames);

console.table(results);
const uploadManager = new UploadManager();
await uploadManager.doUpload(places, chtApi, { contactsOnly: true });
})();

function loadFromCsv(session: ChtSession, chtApi: ChtApi): Promise<Place[]> {
Expand Down
72 changes: 72 additions & 0 deletions scripts/import-cha-users/primary-contact-directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import _ from 'lodash';
import { Config } from '../../src/config';
import { ChtApi, UserInfo } from '../../src/lib/cht-api';
import RemotePlaceCache from '../../src/lib/remote-place-cache';
import Place from '../../src/services/place';

// DirectoryData['subcounty']['cha'] = 'facility_id'
type DirectoryData = {
[key: string]: {
[key: string]: string;
};
};

export default class PrimaryContactDirectory {
private readonly data: DirectoryData;
private readonly chtApi: ChtApi;

private constructor(chtApi: ChtApi, usernameDictionaryData: DirectoryData) {
this.data = usernameDictionaryData;
this.chtApi = chtApi;
}

public static async construct(chtApi: ChtApi): Promise<PrimaryContactDirectory> {
const chuType = Config.getContactType('c_community_health_unit');
const chus = await RemotePlaceCache.getRemotePlaces(chtApi, chuType, chuType.hierarchy[1]);
const chaIds = _.uniq(chus.map(chu => chu.contactId));
const chaDocs = await chtApi.getDocs(chaIds);

const data: DirectoryData = {};
for (const chaDoc of chaDocs) {
const chuId = chaDoc.parent?._id;
const subcountyId = chaDoc.parent?.parent?._id;
if (!data[subcountyId]) {
data[subcountyId] = {};
}

data[subcountyId][chaDoc.name] = chuId;
}

return new PrimaryContactDirectory(chtApi, data);
}

public primaryContactExists(place: Place): boolean {
const subcountyId = place.resolvedHierarchy[1]?.id;
if (!subcountyId) {
return true;
}

const chaName = place.contact.name;
return !!this.data[subcountyId]?.[chaName];
}

public async getUsersAtPlaceWithPC(place: Place): Promise<UserInfo[] | undefined> {
const subcountyId = place.resolvedHierarchy[1]?.id;
if (!subcountyId) {
throw Error('Place has no subcounty id');
}

const chaName = place.contact.name;
const chuId = this.data[subcountyId]?.[chaName];
if (!chuId) {
const subcountyName = place.resolvedHierarchy[1]?.name.formatted;
throw Error(`Place contact is not known within ${subcountyName}`);
}

return this.chtApi.getUsersAtPlace(chuId);
}

public print(): void {
console.log(JSON.stringify(this.data, null, 2));
}
}
23 changes: 23 additions & 0 deletions src/lib/cht-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ export class ChtApi {
const resp = await this.axiosInstance.get(url);
return resp.data;
}

async getDocs(ids: string[]): Promise<any[]> {
const url = `medic/_all_docs`;
console.log('axios.post', url);
const payload = {
keys: ids,
include_docs: true,
};

const resp = await this.axiosInstance.post(url, payload);
return resp.data?.rows.map((row: any) => row.doc).filter(Boolean);
}

async getUsersAtPlace(placeId: string): Promise<UserInfo[]> {
const url = `api/v2/users?facility_id=${placeId}`;
Expand All @@ -184,6 +196,17 @@ export class ChtApi {
}));
}

async getUser(username: string): Promise<UserInfo | undefined> {
const url = `api/v2/users/${username}`;
console.log('axios.get', url);
const resp = await this.axiosInstance.get(url);
const doc = resp.data;
return doc ? {
username: doc.username,
place: doc.place,
} : undefined;
}

async lastSyncAtPlace(placeId: string): Promise<DateTime> {
const userIds = await this.getUsersAtPlace(placeId);
const usernames = userIds.map(userId => userId.username);
Expand Down
Loading

0 comments on commit 0d3fa05

Please sign in to comment.