Skip to content

Commit

Permalink
Merge branch 'main' into 234-multi-facility-small
Browse files Browse the repository at this point in the history
  • Loading branch information
kennsippell committed Dec 24, 2024
2 parents 40e31ef + 4ffd976 commit 90f5f3f
Show file tree
Hide file tree
Showing 12 changed files with 352 additions and 170 deletions.
5 changes: 3 additions & 2 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@types/lodash": "^4.14.201",
"@types/luxon": "^3.4.2",
"@types/node": "^20.8.8",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.6",
"axios": "^1.5.1",
"axios-retry": "^4.0.0",
Expand Down
46 changes: 46 additions & 0 deletions src/lib/cht-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _ from 'lodash';
import { AxiosInstance } from 'axios';
import ChtSession from './cht-session';
import { Config, ContactType } from '../config';
import { DateTime } from 'luxon';
import { UserPayload } from '../services/user-payload';

export type PlacePayload = {
Expand Down Expand Up @@ -113,6 +114,20 @@ export class ChtApi {
return this.axiosInstance.post(url, userInfo);
}

async countContactsUnderPlace(docId: string): Promise<number> {
const url = `medic/_design/medic/_view/contacts_by_depth`;
console.log('axios.get', url);
const resp = await this.axiosInstance.get(url, {
params: {
startkey: JSON.stringify([docId, 0]),
endkey: JSON.stringify([docId, 20]),
include_docs: false,
},
});

return resp.data?.rows?.length || 0;
}

async createUser(user: UserPayload): Promise<void> {
const url = `api/v1/users`;
console.log('axios.post', url);
Expand Down Expand Up @@ -168,4 +183,35 @@ export class ChtApi {
place: doc.place,
}));
}

async lastSyncAtPlace(placeId: string): Promise<DateTime> {
const userIds = await this.getUsersAtPlace(placeId);
const usernames = userIds.map(userId => userId.username);
const result = await this.getLastSyncForUsers(usernames);
return result || DateTime.invalid('unknown');
}

private getLastSyncForUsers = async (usernames: string[]): Promise<DateTime | undefined> => {
if (!usernames?.length) {
return undefined;
}

const url = '/medic-logs/_all_docs';
const keys = usernames.map(username => `connected-user-${username}`);
const payload = {
keys,
include_docs: true,
};

console.log('axios.post', url);
const resp = await this.axiosInstance.post(url, payload);
const timestamps = resp.data?.rows?.map((row: any) => row.doc?.timestamp);

if (!timestamps?.length) {
return undefined;
}

const maxTimestamp = Math.max(timestamps);
return DateTime.fromMillis(maxTimestamp);
};
}
6 changes: 5 additions & 1 deletion src/lib/cht-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export default class ChtSession {
}
}

public get isAdmin(): boolean {
return this.facilityIds.includes(ADMIN_FACILITY_ID);
}

public static async create(authInfo: AuthenticationInfo, username : string, password: string): Promise<ChtSession> {
const sessionToken = await ChtSession.createSessionToken(authInfo, username, password);

Expand All @@ -66,7 +70,7 @@ export default class ChtSession {
isPlaceAuthorized(remotePlace: RemotePlace): boolean {
return this.facilityIds?.length > 0 &&
(
this.facilityIds.includes(ADMIN_FACILITY_ID)
this.isAdmin
|| _.intersection(remotePlace?.lineage, this.facilityIds).length > 0
|| this.facilityIds.includes(remotePlace?.id)
);
Expand Down
98 changes: 63 additions & 35 deletions src/lib/manage-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import _ from 'lodash';
import { DateTime } from 'luxon';

import Auth from './authentication';
import { ChtApi } from './cht-api';
Expand All @@ -13,25 +14,21 @@ import { RemotePlace } from './remote-place-cache';
export const HIERARCHY_ACTIONS = ['move', 'merge', 'delete'];
export type HierarchyAction = typeof HIERARCHY_ACTIONS[number];

const ACTIVE_USER_THRESHOLD_DAYS = 60;
const LARGE_PLACE_THRESHOLD_PLACE_COUNT = 100;

export type WarningInformation = {
affectedPlaceCount: number;
lastSyncDescription: string;
userIsActive: boolean;
lotsOfPlaces: boolean;
};

export default class ManageHierarchyLib {
private constructor() { }

public static async scheduleJob(
formData: any,
contactType: ContactType,
sessionCache: SessionCache,
chtApi: ChtApi,
queueName: IQueue = getChtConfQueue()
) {
const { sourceLineage, destinationLineage, jobParam } = await getJobDetails(formData, contactType, sessionCache, chtApi);

await queueName.add(jobParam);

return {
destinationLineage,
sourceLineage,
success: true
};
public static async scheduleJob(job: JobParams, queueName: IQueue = getChtConfQueue()) {
await queueName.add(job);
}

public static parseHierarchyAction(action: string = ''): HierarchyAction {
Expand All @@ -41,26 +38,57 @@ export default class ManageHierarchyLib {

return action as HierarchyAction;
}

public static async getJobDetails(formData: any, contactType: ContactType, sessionCache: SessionCache, chtApi: ChtApi): Promise<JobParams> {
const hierarchyAction = ManageHierarchyLib.parseHierarchyAction(formData.op);
const sourceLineage = await resolve('source_', formData, contactType, sessionCache, chtApi);
const destinationLineage = hierarchyAction === 'delete' ? [] : await resolve('destination_', formData, contactType, sessionCache, chtApi);

const { sourceId, destinationId } = getSourceAndDestinationIds(hierarchyAction, sourceLineage, destinationLineage);
const jobData = getJobData(hierarchyAction, sourceId, destinationId, chtApi);
const jobName = getJobName(jobData.action, sourceLineage, destinationLineage);
const jobParam: JobParams = {
jobName,
jobData,
};

return jobParam;
}

public static async getWarningInfo(job: JobParams, chtApi: ChtApi): Promise<WarningInformation> {
const { jobData: { sourceId } } = job;
const affectedPlaceCount = await chtApi.countContactsUnderPlace(sourceId);
const lastSyncTime = chtApi.chtSession.isAdmin ? await chtApi.lastSyncAtPlace(sourceId) : DateTime.invalid('must be admin');
const syncBelowThreshold = diffNowInDays(lastSyncTime) < ACTIVE_USER_THRESHOLD_DAYS;
const lastSyncDescription = describeDateTime(lastSyncTime);
return {
affectedPlaceCount,
lastSyncDescription,
userIsActive: syncBelowThreshold && lastSyncDescription !== '-',
lotsOfPlaces: affectedPlaceCount > LARGE_PLACE_THRESHOLD_PLACE_COUNT,
};
}
}

async function getJobDetails(formData: any, contactType: ContactType, sessionCache: SessionCache, chtApi: ChtApi) {
const hierarchyAction = ManageHierarchyLib.parseHierarchyAction(formData.op);
const sourceLineage = await resolve('source_', formData, contactType, sessionCache, chtApi);
const destinationLineage = hierarchyAction === 'delete' ? [] : await resolve('destination_', formData, contactType, sessionCache, chtApi);

const { sourceId, destinationId } = getSourceAndDestinationIds(hierarchyAction, sourceLineage, destinationLineage);
const jobData = getJobData(hierarchyAction, sourceId, destinationId, chtApi);
const jobName = getJobName(jobData.action, sourceLineage, destinationLineage);
const jobParam: JobParams = {
jobName,
jobData,
};
function diffNowInDays(dateTime: DateTime): number {
return -(dateTime?.diffNow('days')?.days || 0);
}

return {
sourceLineage,
destinationLineage,
jobParam
};
function describeDateTime(dateTime: DateTime): string {
if (!dateTime || !dateTime.isValid) {
return '-';
}

const diffNow = diffNowInDays(dateTime);
if (diffNow > 365) {
return 'over a year';
}

if (diffNow < 0) {
return '-';
}

return dateTime.toRelativeCalendar() || '-';
}

function getSourceAndDestinationIds(
Expand All @@ -80,7 +108,7 @@ function getSourceAndDestinationIds(
const destinationIndex = hierarchyAction === 'move' ? 1 : 0;
const destinationId = destinationLineage[destinationIndex]?.id;
if (!destinationId) {
throw Error('Unexpected error: Hierarchy operation due to missing destination information');
throw Error('Unexpected error: Hierarchy operation failed due to missing destination information');
}


Expand All @@ -92,7 +120,7 @@ function getSourceAndDestinationIds(

if (hierarchyAction === 'merge') {
if (destinationId === sourceId) {
throw Error(`Cannot merge "${destinationId}" with self`);
throw Error(`Cannot merge place with self`);
}
}

Expand Down
54 changes: 54 additions & 0 deletions src/liquid/components/manage_hierarchy_form_content.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{% if error %}
<div class="notification is-danger" style="margin: 0;">
{{ error }}
</div>
{% endif %}

{% if success %}
<div data-success="true"></div>
{% endif %}

<input name="place_type" value="{{contactType.name}}" hidden />
<input name="op" value="{{op}}" hidden />

{% if confirm %}
{% include "components/manage_hierarchy_warning" %}
{% endif %}

<div {% if confirm %}hidden{% endif %}>
<section class="section is-small">
<h1 class="subtitle">{{ sourceDescription }}</h1>
{% for hierarchy in sourceHierarchy %}
{%
include "components/search_input.html"
type=contactType.name
hierarchy=hierarchy
data=data
required=hierarchy.required
prefix="source_"
%}
{% endfor %}
</section>

{% if destinationHierarchy.size > 0 %}
<section class="section is-small">
<h1 class="subtitle">{{ destinationDescription }}</h1>
{% for hierarchy in destinationHierarchy %}
{%
include "components/search_input.html"
type=contactType.name
hierarchy=hierarchy
data=data
required=hierarchy.required
prefix="destination_"
%}
{% endfor %}
</section>
{% endif %}

<div class="field is-grouped is-grouped-right">
<div class="control">
<button id="place_create_submit" class="button is-link">{{ op | capitalize }}</button>
</div>
</div>
</div>
49 changes: 49 additions & 0 deletions src/liquid/components/manage_hierarchy_warning.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<section class="section is-small">
<h1 class="subtitle">Confirm you want to {{ op }} this:</h1>
<table class="table is-bordered is-fullwidth">
<tbody>
{% for hierarchy in sourceHierarchy %}
{% capture property_name %}source_{{ hierarchy.property_name }}{% endcapture %}
{%if data[property_name] and data[property_name] != empty %}
<tr>
<td>Source {{ hierarchy.friendly_name }}</td>
<td>{{ data[property_name] }}</td>
</tr>
{% endif %}
{% endfor %}

{% for hierarchy in destinationHierarchy %}
{% capture property_name %}destination_{{ hierarchy.property_name }}{% endcapture %}
{%if data[property_name] and data[property_name] != empty %}
<tr>
<td>Destination {{ hierarchy.friendly_name }}</td>
<td>{{ data[property_name] }}</td>
</tr>
{% endif %}
{% endfor %}

<tr>
<td># of Affected Contacts</td>
<td>{{ warningInfo.affectedPlaceCount }}</td>
</tr>

<tr>
<td>User's Last Sync</td>
<td>{{ warningInfo.lastSyncDescription }}</td>
</tr>
</tbody>
</table>

{% if isPermanent %}<span class="tag is-warning">Cannot be undone</span>{% endif %}
{% if warningInfo.userIsActive %}<span class="tag is-warning">User is active</span>{% endif %}
{% if warningInfo.lotsOfPlaces %}<span class="tag is-warning">Large amount of data</span>{% endif %}

<div class="field is-grouped is-grouped-right">
<div class="control">
<button id="place_create_submit" class="button is-link">Confirm {{ op | capitalize }}</button>
<a href="/manage-hierarchy/{{ op }}/{{ contactType.name }}" class="button">Cancel</a>
</div>
</div>

<input name="confirmed" value="true" hidden />
</section>
Loading

0 comments on commit 90f5f3f

Please sign in to comment.