Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Script to import and reassign CHAs with multiple CHP Areas #241

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
3 changes: 1 addition & 2 deletions scripts/create-user-managers/create-user-managers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Command } from 'commander';

import { AuthenticationInfo, ContactType } from '../../src/config';
import { createUserWithRetries } from '../../src/lib/retry-logic';
import Place from '../../src/services/place';
import RemotePlaceCache, { RemotePlace } from '../../src/lib/remote-place-cache';
import { PropertyValues, UnvalidatedPropertyValue } from '../../src/property-value';
Expand Down Expand Up @@ -72,7 +71,7 @@ async function createUserManager(username: string, placeDocId: string, chtApi: t
}

console.log(`Creating user with payload: ${JSON.stringify(userPayload)}`);
await createUserWithRetries(userPayload, chtApi);
await userPayload.create(chtApi);
return userPayload;
}

Expand Down
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}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a higher chance of the CHA's phone number being more unique than their name

}

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);
}
}

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

import { Config } from '../../src/config';
import { ChtApi } from '../../src/lib/cht-api';
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 { 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',
domain: 'localhost:5988',
useHttp: true,
};
const username = 'medic';
const password = 'password';
const csvFilePath = './Import CHAs.csv';

(async function() {
const session = await ChtSession.create(authInfo, username, password);
const chtApi = new ChtApi(session);
const places: Place[] = await loadFromCsv(session, chtApi);

assertAllPlacesValid(places);

const usernames = await PrimaryContactDirectory.construct(chtApi);

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

await createMultiplaceUsers(placesNeedingNewUser, chtApi);

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

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

function loadFromCsv(session: ChtSession, chtApi: ChtApi): Promise<Place[]> {
const csvBuffer = fs.readFileSync(csvFilePath);
const chuType = Config.getContactType('c_community_health_unit');
chuType.username_from_place = false;

const sessionCache = SessionCache.getForSession(session);
return PlaceFactory.createFromCsv(csvBuffer, chuType, sessionCache, chtApi);
}

function assertAllPlacesValid(places: Place[]) {
const withErrors = places.filter(place => place.hasValidationErrors);
for (const place of withErrors) {
const placeDescription = `"${place.hierarchyProperties.replacement.original}" at "${place.hierarchyProperties.SUBCOUNTY.original}"`;
console.log(`Place ${placeDescription} has validation errors:`);
const validationErrors = Object.values(place.validationErrors || {});
for (const validationError of validationErrors) {
console.log(`* ${validationError}`);
}
}

if (withErrors.length) {
throw Error('Some places had validation errors. See logs.');
}

const notReplacement = places.find(place => !place.resolvedHierarchy[0]);
if (notReplacement) {
throw Error('Invalid CSV Format. Some rows are not CHU replacements');
}
}
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));
}
}
25 changes: 24 additions & 1 deletion src/lib/cht-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class ChtApi {
}

async createUser(user: UserPayload): Promise<void> {
const url = `api/v1/users`;
const url = `api/v3/users`;
console.log('axios.post', url);
const axiosRequestionConfig = {
'axios-retry': { retries: 0 }, // upload-manager handles retries for this
Expand Down 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