From e47d3ccc25f98e566de3c2ac7423b376dfb95d37 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:54:05 +0100 Subject: [PATCH 01/15] feat: setup d2-logger config --- package.json | 1 + src/data/entities/Instance.ts | 23 ++++++++++++++ src/scripts/common.ts | 6 ++++ src/utils/logger.ts | 37 +++++++++++++++++++++++ yarn.lock | 56 +++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 src/data/entities/Instance.ts create mode 100644 src/utils/logger.ts diff --git a/package.json b/package.json index 73c0a08f..c4efa5b8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", "@eyeseetea/d2-api": "1.16.0-beta.9", + "@eyeseetea/d2-logger": "^1.1.0", "@eyeseetea/d2-ui-components": "v2.9.0-beta.2", "@eyeseetea/feedback-component": "0.0.3", "@material-ui/core": "4.12.4", diff --git a/src/data/entities/Instance.ts b/src/data/entities/Instance.ts new file mode 100644 index 00000000..8ec99106 --- /dev/null +++ b/src/data/entities/Instance.ts @@ -0,0 +1,23 @@ +export interface InstanceData { + url: string; + username?: string; + password?: string; +} + +export class Instance { + public readonly url: string; + private username: string | undefined; + private password: string | undefined; + + constructor(data: InstanceData) { + this.url = data.url; + this.username = data.username; + this.password = data.password; + } + + public get auth(): { username: string; password: string } | undefined { + return this.username && this.password + ? { username: this.username, password: this.password } + : undefined; + } +} diff --git a/src/scripts/common.ts b/src/scripts/common.ts index bcada9fb..bce39725 100644 --- a/src/scripts/common.ts +++ b/src/scripts/common.ts @@ -1,4 +1,5 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { Instance } from "../data/entities/Instance"; type Auth = { username: string; @@ -15,3 +16,8 @@ export function getD2ApiFromArgs(args: D2ApiArgs): D2Api { return new D2Api({ baseUrl: url, auth }); } + +export function getInstance(args: D2ApiArgs): Instance { + const instance = new Instance({ url: args.url, ...args.auth }); + return instance; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..0c681813 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,37 @@ +import { ConsoleLogger, ProgramLogger, initLogger, BatchLogContent } from "@eyeseetea/d2-logger"; +import { Instance } from "../data/entities/Instance"; +import { Id } from "../domain/entities/Ref"; +import { RTSL_ZEBRA_ORG_UNIT_ID } from "../data/repositories/consts/DiseaseOutbreakConstants"; + +const LOGS_PROGRAM = "usU7YBzuhaE"; +const MESSAGE_DATA_ELEMENT = "OCXD513wyZU"; +const MESSAGE_TYPE_DATA_ELEMENT = "UF08oi330lh"; + +export let logger: ProgramLogger | ConsoleLogger; +export type { BatchLogContent }; + +export async function setupLogger( + instance: Instance, + options?: { isDebug?: boolean; orgUnitId?: Id } +): Promise { + const { isDebug = false, orgUnitId } = options ?? {}; + + logger = await initLogger({ + type: "program", + debug: isDebug, + baseUrl: instance.url, + auth: instance.auth, + programId: LOGS_PROGRAM, + organisationUnitId: orgUnitId || RTSL_ZEBRA_ORG_UNIT_ID, + dataElements: { + messageId: MESSAGE_DATA_ELEMENT, + messageTypeId: MESSAGE_TYPE_DATA_ELEMENT, + }, + }); +} + +export async function setupLoggerForTesting(): Promise { + logger = await initLogger({ + type: "console", + }); +} diff --git a/yarn.lock b/yarn.lock index cfcf1574..45cad0ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3419,6 +3419,36 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== +"@eyeseetea/d2-api@1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.14.0.tgz#546398b00b9f01b60a72ecba648a35f57b6f7559" + integrity sha512-gVNXfK8sk1STuM8QDed0JY8DM63SwI3UJXeKsyzJjHtPIqi76ukdCYAo8XwtfqhGdZIwIMrrWPEOmAJ3dbvCKQ== + dependencies: + "@babel/runtime" "^7.5.4" + "@dhis2/d2-i18n" "^1.0.5" + "@types/prettier" "^1.18.3" + "@types/qs" "^6.5.3" + abort-controller "3.0.0" + argparse "^2.0.1" + axios "0.19.2" + axios-debug-log "^0.6.2" + axios-mock-adapter "1.18.2" + btoa "^1.2.1" + cronstrue "^1.81.0" + cryptr "^4.0.2" + d2 "^31.8.1" + dotenv "^8.0.0" + express "^4.17.1" + form-data "^4.0.0" + iconv-lite "0.6.2" + isomorphic-fetch "3.0.0" + lodash "^4.17.15" + log4js "^4.5.1" + node-schedule "^1.3.2" + qs "^6.9.0" + react "^16.12.0" + yargs "^14.0.0" + "@eyeseetea/d2-api@1.16.0-beta.9": version "1.16.0-beta.9" resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.9.tgz#bd318b62d8c94ea4e490c8e9b90461869f748c25" @@ -3450,6 +3480,17 @@ side-channel "^1.0.4" yargs "^14.0.0" +"@eyeseetea/d2-logger@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-logger/-/d2-logger-1.1.0.tgz#9a78b5e5c624bfc7550c7cab019ab9321d17ded0" + integrity sha512-2abLQDoT0A9wAVt2h2vr8UcRqb5ygZrUrU51lT9Ljs/X1wgFuKM/fqghOe714xvieFmPc/6K3o3FfabQ0rSCCg== + dependencies: + "@babel/runtime" "^7.5.4" + "@eyeseetea/d2-api" "1.14.0" + cmd-ts "0.13.0" + real-cancellable-promise "^1.1.2" + typed-immutable-map "0.2.0" + "@eyeseetea/d2-ui-components@v2.9.0-beta.2": version "2.9.0-beta.2" resolved "https://registry.yarnpkg.com/@eyeseetea/d2-ui-components/-/d2-ui-components-2.9.0-beta.2.tgz#7df5ea659ed1d487d78301f8e0c1f735dcb9f6e0" @@ -5533,6 +5574,16 @@ clsx@^2.1.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +cmd-ts@0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/cmd-ts/-/cmd-ts-0.13.0.tgz#57bdbc5dc95eb5a3503ab3ac9591c91427a79fa1" + integrity sha512-nsnxf6wNIM/JAS7T/x/1JmbEsjH0a8tezXqqpaL0O6+eV0/aDEnRxwjxpu0VzDdRcaC1ixGSbRlUuf/IU59I4g== + dependencies: + chalk "^4.0.0" + debug "^4.3.4" + didyoumean "^1.2.2" + strip-ansi "^6.0.0" + cmd-ts@^0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/cmd-ts/-/cmd-ts-0.12.1.tgz#5ddf69f27887e7380ce6d50a07a3850cb82ea3f7" @@ -11060,6 +11111,11 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" +typed-immutable-map@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/typed-immutable-map/-/typed-immutable-map-0.2.0.tgz#42c16f261fc0a75da0358298a2aae248a0c0b7d5" + integrity sha512-eraiB5BugF8ZjfpcQWcBArZUPYBUQ5ziy8w/BDvdb7uKBTb5aUyd+jWkiNn+ADvH/Wtl7w2ppVNDSoIGaMWWeg== + typed-immutable-map@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/typed-immutable-map/-/typed-immutable-map-0.1.1.tgz#4f7d67c6afa3daa2eaa09c0afa1b9d8ef35a6045" From 1122a06a8c4bdf10a2685fab28c2aba26b1f0c46 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:00:10 +0100 Subject: [PATCH 02/15] feat: reduce indentation --- src/data/repositories/AlertD2Repository.ts | 2 +- src/scripts/mapDiseaseOutbreakToAlerts.ts | 422 ++++++++++----------- 2 files changed, 206 insertions(+), 218 deletions(-) diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index 7a850837..3bd299d7 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -79,7 +79,7 @@ export class AlertD2Repository implements AlertRepository { }); } - private async getTrackedEntitiesByTEACodeAsync(options: { + async getTrackedEntitiesByTEACodeAsync(options: { program: Id; orgUnit: Id; ouMode: "SELECTED" | "DESCENDANTS"; diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index a46097e6..b3b2c4ef 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -1,6 +1,7 @@ -import { command, run } from "cmd-ts"; +import { boolean, command, flag, run } from "cmd-ts"; +import { setupLogger, logger } from "../utils/logger"; import path from "path"; -import { getD2ApiFromArgs } from "./common"; +import { getD2ApiFromArgs, getInstance } from "./common"; import { RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, @@ -25,9 +26,7 @@ import { getNotificationOptionsFromTrackedEntity } from "../data/repositories/ut import { AlertData } from "../domain/entities/alert/AlertData"; import { DataSource } from "../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { FutureData } from "../data/api-futures"; import { Alert } from "../domain/entities/alert/Alert"; -import { AlertOptions } from "../domain/repositories/AlertRepository"; import { Option } from "../domain/entities/Ref"; //TO DO : Fetch from metadata on app load @@ -39,8 +38,15 @@ function main() { const cmd = command({ name: path.basename(__filename), description: "Map national event ID to Zebra Alert Events with no event ID", - args: {}, - handler: async () => { + args: { + debug: flag({ + type: boolean, + defaultValue: () => true, + long: "debug", + description: "Option to print also logs in console", + }), + }, + handler: async args => { if (!process.env.VITE_DHIS2_BASE_URL) throw new Error("VITE_DHIS2_BASE_URL must be set in the .env file"); @@ -63,249 +69,179 @@ function main() { }; const api = getD2ApiFromArgs(envVars); + const instance = getInstance(envVars); + const alertRepository = new AlertD2Repository(api); const alertSyncRepository = new AlertSyncDataStoreRepository(api); const notificationRepository = new NotificationD2Repository(api); const optionsRepository = new OptionsD2Repository(api); - const notifyWatchStaffUseCase = new NotifyWatchStaffUseCase(notificationRepository); - return Future.joinObj({ - alertTrackedEntities: getAlertTrackedEntities(), - hazardTypes: optionsRepository.getHazardTypesByCode(), - suspectedDiseases: optionsRepository.getSuspectedDiseases(), - }).run( - ({ alertTrackedEntities, hazardTypes, suspectedDiseases }) => { - const alertsWithNoEventId = - getAlertsWithNoNationalEventId(alertTrackedEntities); - - console.debug( - `${alertsWithNoEventId.length} event(s) found in the Zebra Alerts program with no national event id.` - ); - - return _(alertsWithNoEventId) - .groupBy(alert => alert.dataSource) - .values() - .forEach(alertsByDataSource => { - const uniqueFilters = getUniqueFilters(alertsByDataSource); - - return uniqueFilters.forEach(filter => - getNationalTrackedEntities({ - id: filter.filterId, - value: filter.filterValue, - }).run( - nationalTrackedEntities => { - if (nationalTrackedEntities.length > 1) { - const outbreakKey = getOutbreakKey({ - dataSource: filter.dataSource, - outbreakValue: filter.filterValue, + await setupLogger(instance, { isDebug: args.debug }); + + const { hazardTypes, suspectedDiseases } = await getOptions(optionsRepository); + const alertTrackedEntities = await getAlertTrackedEntities(); + const alertsWithNoEventId = getAlertsWithNoNationalEventId(alertTrackedEntities); + logger.info( + `${alertsWithNoEventId.length} event(s) found in the Zebra Alerts program with no national event id.` + ); + + return _(alertsWithNoEventId) + .groupBy(alert => alert.dataSource) + .values() + .forEach(alertsByDataSource => { + const uniqueFilters = getUniqueFilters(alertsByDataSource); + + return uniqueFilters.forEach(filter => { + getNationalTrackedEntities(alertRepository, { + id: filter.filterId, + value: filter.filterValue, + }).then(nationalTrackedEntities => { + if (nationalTrackedEntities.length > 1) { + const outbreakKey = getOutbreakKey({ + dataSource: filter.dataSource, + outbreakValue: filter.filterValue, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + + logger.error( + `More than 1 National event found for ${outbreakKey} outbreak.` + ); + + return undefined; + } + + return alertsByDataSource + .filter( + alertData => alertData.outbreakData.value === filter.filterValue + ) + .forEach(alertData => { + const { alert, dataSource, outbreakData } = alertData; + + const outbreakType = + dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + ? "disease" + : "hazard"; + const outbreakName = getOutbreakKey({ + dataSource: dataSource, + outbreakValue: outbreakData.value, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + + alertSyncRepository + .getAlertTrackedEntity(alert) + .toPromise() + .then(alertTrackedEntity => { + if (nationalTrackedEntities.length === 0) { + return notifyNationalWatchStaff( + alertTrackedEntity, + outbreakType, + outbreakName + ); + } + + const nationalTrackedEntity = + nationalTrackedEntities[0]; + if (!nationalTrackedEntity) + throw new Error(`No tracked entity found.`); + + return updateAlertData({ + alert: alert, + alertTrackedEntity: alertTrackedEntity, + nationalTrackedEntity: alertTrackedEntity, hazardTypes: hazardTypes, suspectedDiseases: suspectedDiseases, }); - - console.error( - `More than 1 National event found for ${outbreakKey} outbreak.` - ); - - return undefined; - } - - return alertsByDataSource - .filter( - alertData => - alertData.outbreakData.value === - filter.filterValue - ) - .forEach(alertData => { - const { alert, dataSource, outbreakData } = - alertData; - - const outbreakType = - dataSource === - DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - ? "disease" - : "hazard"; - const outbreakName = getOutbreakKey({ - dataSource: dataSource, - outbreakValue: outbreakData.value, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, - }); - - alertSyncRepository - .getAlertTrackedEntity(alert) - .run( - alertTrackedEntity => { - if ( - nationalTrackedEntities.length === 0 - ) { - return notifyNationalWatchStaff( - alertTrackedEntity, - outbreakType, - outbreakName - ).run( - () => - console.debug( - "Successfully notified all national watch staff." - ), - error => console.error(error) - ); - } - - const nationalTrackedEntity = - nationalTrackedEntities[0]; - if (!nationalTrackedEntity) - throw new Error( - `No tracked entity found.` - ); - - const alertOptions = - mapTrackedEntityAttributesToAlertOptions( - nationalTrackedEntity, - alertTrackedEntity - ); - - alertRepository - .updateAlerts(alertOptions) - .run( - () => - console.debug( - "Successfully updated alert." - ), - error => console.error(error) - ); - - saveAlertSyncData({ - alertOptions: alertOptions, - alert: alert, - hazardTypes: hazardTypes, - suspectedDiseases: - suspectedDiseases, - }).run( - () => - console.debug( - `Saved alert data for ${outbreakName} ${outbreakType}.` - ), - error => console.error(error) - ); - }, - error => console.error(error) - ); - }); - }, - error => console.error(error) - ) - ); + }); + }); }); - }, - error => console.error(error) - ); + }); + }); - function getAlertTrackedEntities(): FutureData { - return alertRepository.getTrackedEntitiesByTEACode({ + async function getAlertTrackedEntities(): Promise { + return alertRepository.getTrackedEntitiesByTEACodeAsync({ program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, ouMode: "DESCENDANTS", }); } - function getAlertsWithNoNationalEventId( - alertTrackedEntities: D2TrackerTrackedEntity[] - ): AlertData[] { - return _(alertTrackedEntities) - .compactMap(trackedEntity => { - const nationalEventId = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID - ); - const hazardType = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID - ); - const diseaseType = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID - ); - - const outbreakData = diseaseType - ? { id: diseaseType.attribute, value: diseaseType.value } - : hazardType - ? { id: hazardType.value, value: hazardType.value } - : undefined; - - if (!outbreakData) return undefined; - if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) - throw new Error("Tracked entity not found"); - - const alertData: AlertData = { - alert: { - id: trackedEntity.trackedEntity, - district: trackedEntity.orgUnit, - }, - outbreakData: outbreakData, - dataSource: diseaseType - ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, - }; - - return !nationalEventId && (hazardType || diseaseType) - ? alertData - : undefined; - }) - .value(); - } - - function getNationalTrackedEntities(filter: { - id: string; - value: string; - }): FutureData { - return alertRepository.getTrackedEntitiesByTEACode({ - program: RTSL_ZEBRA_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - ouMode: "SELECTED", - filter: filter, - }); - } - - function notifyNationalWatchStaff( + async function notifyNationalWatchStaff( alertTrackedEntity: D2TrackerTrackedEntity, alertOutbreakType: string, outbreakName: string - ): FutureData { - console.debug( + ): Promise { + logger.debug( `There is no national event with ${outbreakName} ${alertOutbreakType} type.` ); - return getUserGroupByCode( + const userGroup = await getUserGroupByCode( api, RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE - ).flatMap(userGroup => { - const notificationOptions = - getNotificationOptionsFromTrackedEntity(alertTrackedEntity); - - return notifyWatchStaffUseCase.execute(outbreakName, notificationOptions, [ - userGroup, - ]); - }); + ).toPromise(); + const notificationOptions = + getNotificationOptionsFromTrackedEntity(alertTrackedEntity); + + notifyWatchStaffUseCase + .execute(outbreakName, notificationOptions, [userGroup]) + .toPromise() + .then(() => logger.success("Successfully notified all national watch staff.")); } - function saveAlertSyncData(options: { - alertOptions: AlertOptions; + async function updateAlertData(options: { alert: Alert; + alertTrackedEntity: D2TrackerTrackedEntity; + nationalTrackedEntity: D2TrackerTrackedEntity; hazardTypes: Option[]; suspectedDiseases: Option[]; - }) { - const { alert, alertOptions, hazardTypes, suspectedDiseases } = options; + }): Promise { + const { + alert, + alertTrackedEntity, + nationalTrackedEntity, + hazardTypes, + suspectedDiseases, + } = options; + + const alertOptions = mapTrackedEntityAttributesToAlertOptions( + nationalTrackedEntity, + alertTrackedEntity + ); const { dataSource, eventId, hazardTypeCode, suspectedDiseaseCode } = alertOptions; - return alertSyncRepository.saveAlertSyncData({ - dataSource: dataSource, - hazardTypeCode: hazardTypeCode, - suspectedDiseaseCode: suspectedDiseaseCode, - nationalDiseaseOutbreakEventId: eventId, - alert: alert, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, + await alertRepository + .updateAlerts(alertOptions) + .toPromise() + .then(() => logger.success("Successfully updated alert.")); + + return alertSyncRepository + .saveAlertSyncData({ + dataSource: dataSource, + hazardTypeCode: hazardTypeCode, + suspectedDiseaseCode: suspectedDiseaseCode, + nationalDiseaseOutbreakEventId: eventId, + alert: alert, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }) + .toPromise() + .then(() => logger.success("Successfully saved alert sync data.")); + } + + async function getNationalTrackedEntities( + alertRepository: AlertD2Repository, + filter: { + id: string; + value: string; + } + ): Promise { + return alertRepository.getTrackedEntitiesByTEACodeAsync({ + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + ouMode: "SELECTED", + filter: filter, }); } }, @@ -314,6 +250,58 @@ function main() { run(cmd, process.argv.slice(2)); } +function getOptions( + optionsRepository: OptionsD2Repository +): + | { hazardTypes: any; suspectedDiseases: any } + | PromiseLike<{ hazardTypes: any; suspectedDiseases: any }> { + return Future.joinObj({ + hazardTypes: optionsRepository.getHazardTypesByCode(), + suspectedDiseases: optionsRepository.getSuspectedDiseases(), + }).toPromise(); +} + +function getAlertsWithNoNationalEventId( + alertTrackedEntities: D2TrackerTrackedEntity[] +): AlertData[] { + return _(alertTrackedEntities) + .compactMap(trackedEntity => { + const nationalEventId = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID + ); + const hazardType = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID + ); + const diseaseType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID); + + const outbreakData = diseaseType + ? { id: diseaseType.attribute, value: diseaseType.value } + : hazardType + ? { id: hazardType.value, value: hazardType.value } + : undefined; + + if (!outbreakData) return undefined; + if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) + throw new Error("Tracked entity not found"); + + const alertData: AlertData = { + alert: { + id: trackedEntity.trackedEntity, + district: trackedEntity.orgUnit, + }, + outbreakData: outbreakData, + dataSource: diseaseType + ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, + }; + + return !nationalEventId && (hazardType || diseaseType) ? alertData : undefined; + }) + .value(); +} + function getUniqueFilters(alerts: AlertData[]): { filterId: string; filterValue: string; From c946d9e80ea12a9fda7f11b49cf80b4f6639d45f Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:39:12 +0100 Subject: [PATCH 03/15] refactor: move script logic to a use case --- src/data/repositories/AlertD2Repository.ts | 32 +- .../repositories/AlertDataD2Repository.ts | 151 +++++++++ .../AlertSyncDataStoreRepository.ts | 22 +- .../DiseaseOutbreakEventD2Repository.ts | 18 + .../repositories/NotificationD2Repository.ts | 38 ++- .../repositories/UserGroupD2Repository.ts | 26 ++ .../DiseaseOutbreakEventTestRepository.ts | 67 ++++ .../repositories/utils/AlertOutbreakMapper.ts | 76 +---- src/data/repositories/utils/MetadataHelper.ts | 22 +- .../repositories/utils/NotificationMapper.ts | 22 -- .../utils/getAllTrackedEntities.ts | 29 +- src/domain/entities/UserGroup.ts | 3 + src/domain/entities/alert/AlertData.ts | 29 +- .../alert/AlertSynchronizationData.ts | 41 +++ .../repositories/AlertDataRepository.ts | 6 + src/domain/repositories/AlertRepository.ts | 3 +- .../repositories/AlertSyncRepository.ts | 7 +- .../DiseaseOutbreakEventRepository.ts | 4 + .../repositories/NotificationRepository.ts | 15 +- .../repositories/UserGroupRepository.ts | 6 + .../MapDiseaseOutbreakToAlertsUseCase.ts | 6 +- src/domain/usecases/MappingScriptUseCase.ts | 214 ++++++++++++ .../usecases/NotifyWatchStaffUseCase.ts | 37 --- src/scripts/common.ts | 28 ++ src/scripts/mapDiseaseOutbreakToAlerts.ts | 309 ++---------------- 25 files changed, 700 insertions(+), 511 deletions(-) create mode 100644 src/data/repositories/AlertDataD2Repository.ts create mode 100644 src/data/repositories/UserGroupD2Repository.ts delete mode 100644 src/data/repositories/utils/NotificationMapper.ts create mode 100644 src/domain/entities/UserGroup.ts create mode 100644 src/domain/entities/alert/AlertSynchronizationData.ts create mode 100644 src/domain/repositories/AlertDataRepository.ts create mode 100644 src/domain/repositories/UserGroupRepository.ts create mode 100644 src/domain/usecases/MappingScriptUseCase.ts delete mode 100644 src/domain/usecases/NotifyWatchStaffUseCase.ts diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index 3bd299d7..2d014a7d 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -19,25 +19,20 @@ import { import { Maybe } from "../../utils/ts-utils"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Alert } from "../../domain/entities/alert/Alert"; - -export type Filter = { - id: Id; - value: Maybe; -}; +import { OutbreakData } from "../../domain/entities/alert/AlertData"; export class AlertD2Repository implements AlertRepository { constructor(private api: D2Api) {} updateAlerts(alertOptions: AlertOptions): FutureData { - const { dataSource, eventId, hazardTypeCode, incidentStatus, suspectedDiseaseCode } = - alertOptions; - const filter = this.getAlertFilter(dataSource, suspectedDiseaseCode, hazardTypeCode); + const { dataSource, eventId, incidentStatus, outbreakValue } = alertOptions; + const outbreakData = this.getAlertOutbreakData(dataSource, outbreakValue); return this.getTrackedEntitiesByTEACode({ program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, ouMode: "DESCENDANTS", - filter: filter, + filter: outbreakData, }).flatMap(alertTrackedEntities => { const alertsToMap: Alert[] = alertTrackedEntities.map(trackedEntity => ({ id: trackedEntity.trackedEntity || "", @@ -79,11 +74,11 @@ export class AlertD2Repository implements AlertRepository { }); } - async getTrackedEntitiesByTEACodeAsync(options: { + private async getTrackedEntitiesByTEACodeAsync(options: { program: Id; orgUnit: Id; ouMode: "SELECTED" | "DESCENDANTS"; - filter?: Filter; + filter?: OutbreakData; }): Promise { const { program, orgUnit, ouMode, filter } = options; const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; @@ -132,25 +127,24 @@ export class AlertD2Repository implements AlertRepository { } } - getTrackedEntitiesByTEACode(options: { + private getTrackedEntitiesByTEACode(options: { program: Id; orgUnit: Id; ouMode: "SELECTED" | "DESCENDANTS"; - filter?: Filter; + filter?: OutbreakData; }): FutureData { return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(options)); } - private getAlertFilter( + private getAlertOutbreakData( dataSource: DataSource, - suspectedDiseaseCode: Maybe, - hazardTypeCode: Maybe - ): Filter { + outbreakValue: Maybe + ): OutbreakData { switch (dataSource) { case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: - return { id: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, value: suspectedDiseaseCode }; + return { id: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, value: outbreakValue }; case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: - return { id: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, value: hazardTypeCode }; + return { id: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, value: outbreakValue }; } } } diff --git a/src/data/repositories/AlertDataD2Repository.ts b/src/data/repositories/AlertDataD2Repository.ts new file mode 100644 index 00000000..fb59344e --- /dev/null +++ b/src/data/repositories/AlertDataD2Repository.ts @@ -0,0 +1,151 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { AlertData, OutbreakData } from "../../domain/entities/alert/AlertData"; +import { AlertDataRepository } from "../../domain/repositories/AlertDataRepository"; +import { + D2TrackerTrackedEntity, + TrackedEntitiesGetResponse, +} from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { + RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, + RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, + RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID, + RTSL_ZEBRA_ALERTS_PROGRAM_ID, + RTSL_ZEBRA_ORG_UNIT_ID, +} from "./consts/DiseaseOutbreakConstants"; +import { Id } from "../../domain/entities/Ref"; +import { FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import _ from "../../domain/entities/generic/Collection"; +import { getTEAttributeById } from "./utils/MetadataHelper"; +import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { mapTrackedEntityAttributesToNotificationOptions } from "./utils/AlertOutbreakMapper"; + +export class AlertDataD2Repository implements AlertDataRepository { + constructor(private api: D2Api) {} + + get(): FutureData { + return this.getAlertTrackedEntities().flatMap(alertTEIs => { + const alertsWithNoEventId = this.getAlertData(alertTEIs); + + return alertsWithNoEventId; + }); + } + + private getAlertData(alertTrackedEntities: D2TrackerTrackedEntity[]): FutureData { + const alertsWithNoEventId = _(alertTrackedEntities) + .compactMap(trackedEntity => { + const nationalEventId = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID + ); + const hazardType = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID + ); + const diseaseType = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID + ); + + const notificationOptions = + mapTrackedEntityAttributesToNotificationOptions(trackedEntity); + + const outbreakData = diseaseType + ? { id: diseaseType.attribute, value: diseaseType.value } + : hazardType + ? { id: hazardType.value, value: hazardType.value } + : undefined; + + if (!outbreakData) return undefined; + if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) + throw new Error("Tracked entity not found"); + + const alertData: AlertData = { + alert: { + id: trackedEntity.trackedEntity, + district: trackedEntity.orgUnit, + }, + outbreakData: outbreakData, + dataSource: diseaseType + ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, + notificationOptions: notificationOptions, + }; + + return !nationalEventId && (hazardType || diseaseType) ? alertData : undefined; + }) + .value(); + + return Future.success(alertsWithNoEventId); + } + + private async getTrackedEntitiesByTEACodeAsync(options: { + program: Id; + orgUnit: Id; + ouMode: "SELECTED" | "DESCENDANTS"; + filter?: OutbreakData; + }): Promise { + const { program, orgUnit, ouMode, filter } = options; + const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; + + const pageSize = 250; + let page = 1; + let result: TrackedEntitiesGetResponse; + + try { + do { + result = await this.api.tracker.trackedEntities + .get({ + program: program, + orgUnit: orgUnit, + ouMode: ouMode, + totalPages: true, + page: page, + pageSize: pageSize, + fields: { + attributes: true, + orgUnit: true, + trackedEntity: true, + trackedEntityType: true, + enrollments: { + events: { + createdAt: true, + dataValues: { + dataElement: true, + value: true, + }, + event: true, + }, + }, + }, + filter: filter ? `${filter.id}:eq:${filter.value}` : undefined, + }) + .getData(); + + d2TrackerTrackedEntities.push(...result.instances); + + page++; + } while (result.page < Math.ceil((result.total as number) / pageSize)); + return d2TrackerTrackedEntities; + } catch { + return []; + } + } + + private getTrackedEntitiesByTEACode(options: { + program: Id; + orgUnit: Id; + ouMode: "SELECTED" | "DESCENDANTS"; + filter?: OutbreakData; + }): FutureData { + return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(options)); + } + + private getAlertTrackedEntities(): FutureData { + return this.getTrackedEntitiesByTEACode({ + program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + ouMode: "DESCENDANTS", + }); + } +} diff --git a/src/data/repositories/AlertSyncDataStoreRepository.ts b/src/data/repositories/AlertSyncDataStoreRepository.ts index cf054678..787d32d8 100644 --- a/src/data/repositories/AlertSyncDataStoreRepository.ts +++ b/src/data/repositories/AlertSyncDataStoreRepository.ts @@ -6,10 +6,13 @@ import { AlertSyncRepository, } from "../../domain/repositories/AlertSyncRepository"; import { apiToFuture, FutureData } from "../api-futures"; -import { getOutbreakKey, getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; +import { getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; import { Maybe } from "../../utils/ts-utils"; import { DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; -import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; +import { + AlertSynchronizationData, + getOutbreakKey, +} from "../../domain/entities/alert/AlertSynchronizationData"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { RTSL_ZEBRA_ALERTS_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import { assertOrError } from "./utils/AssertOrError"; @@ -24,14 +27,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { } saveAlertSyncData(options: AlertSyncOptions): FutureData { - const { - alert, - dataSource, - hazardTypeCode, - suspectedDiseaseCode, - hazardTypes, - suspectedDiseases, - } = options; + const { alert, outbreakValue, dataSource, hazardTypes, suspectedDiseases } = options; return this.getAlertTrackedEntity(alert).flatMap(alertTrackedEntity => { const verificationStatus = getAlertValueFromMap( @@ -42,7 +38,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { if (verificationStatus === VerificationStatus.RTSL_ZEB_AL_OS_VERIFICATION_VERIFIED) { const outbreakKey = getOutbreakKey({ dataSource: dataSource, - outbreakValue: suspectedDiseaseCode || hazardTypeCode, + outbreakValue: outbreakValue, hazardTypes: hazardTypes, suspectedDiseases: suspectedDiseases, }); @@ -72,7 +68,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { }); } - public getAlertTrackedEntity(alert: Alert): FutureData { + private getAlertTrackedEntity(alert: Alert): FutureData { return apiToFuture( this.api.tracker.trackedEntities.get({ program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, @@ -104,7 +100,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { trackedEntity: D2TrackerTrackedEntity, outbreakKey: string ): AlertSynchronizationData { - const { alert, nationalDiseaseOutbreakEventId, dataSource } = options; + const { alert, dataSource, nationalDiseaseOutbreakEventId } = options; const outbreakType = dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index ebb477d8..dc780882 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -13,6 +13,7 @@ import { getProgramTEAsMetadata } from "./utils/MetadataHelper"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; +import { OutbreakData } from "../../domain/entities/alert/AlertData"; export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRepository { constructor(private api: D2Api) {} @@ -42,6 +43,23 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } + getEventByDiseaseOrHazardType( + filter: OutbreakData + ): FutureData { + return Future.fromPromise( + getAllTrackedEntitiesAsync( + this.api, + RTSL_ZEBRA_PROGRAM_ID, + RTSL_ZEBRA_ORG_UNIT_ID, + filter + ) + ).map(trackedEntities => { + return trackedEntities.map(trackedEntity => { + return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); + }); + }); + } + save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData { return getProgramTEAsMetadata(this.api, RTSL_ZEBRA_PROGRAM_ID).flatMap( teasMetadataResponse => { diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index c63d4429..ed33aea6 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -1,17 +1,43 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; import { - Notification, + NotificationOptions, NotificationRepository, } from "../../domain/repositories/NotificationRepository"; import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; - +import { UserGroup } from "../../domain/entities/UserGroup"; +import { AlertData } from "../../domain/entities/alert/AlertData"; export class NotificationD2Repository implements NotificationRepository { constructor(private api: D2Api) {} - save(notification: Notification): FutureData { - return apiToFuture(this.api.messageConversations.post(notification)).flatMap(() => - Future.success(undefined) - ); + notifyNationalWatchStaff( + alertData: AlertData, + outbreakName: string, + userGroups: UserGroup[] + ): FutureData { + const { notificationOptions } = alertData; + + return apiToFuture( + this.api.messageConversations.post({ + subject: `New Outbreak Alert: ${outbreakName} in zm Zambia Ministry of Health`, + text: buildNotificationText(outbreakName, notificationOptions), + userGroups: userGroups, + }) + ).flatMap(() => Future.success(undefined)); } } + +function buildNotificationText(outbreakKey: string, notificationData: NotificationOptions): string { + const { detectionDate, emergenceDate, incidentManager, notificationDate, verificationStatus } = + notificationData; + + return `There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. + +Please see the details of the outbreak below: + +Emergence date: ${emergenceDate} +Detection Date : ${detectionDate} +Notification Date : ${notificationDate} +Incident Manager : ${incidentManager} +Verification Status : ${verificationStatus}`; +} diff --git a/src/data/repositories/UserGroupD2Repository.ts b/src/data/repositories/UserGroupD2Repository.ts new file mode 100644 index 00000000..4f6caa35 --- /dev/null +++ b/src/data/repositories/UserGroupD2Repository.ts @@ -0,0 +1,26 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { UserGroupRepository } from "../../domain/repositories/UserGroupRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { assertOrError } from "./utils/AssertOrError"; +import { UserGroup } from "../../domain/entities/UserGroup"; + +export class UserGroupD2Repository implements UserGroupRepository { + constructor(private api: D2Api) {} + + getUserGroupByCode(code: string): FutureData { + return apiToFuture( + this.api.metadata.get({ + userGroups: { + fields: { + id: true, + }, + filter: { + code: { eq: code }, + }, + }, + }) + ) + .flatMap(response => assertOrError(response.userGroups[0], `User group ${code}`)) + .map(userGroup => userGroup); + } +} diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index aa12ba4a..ea322509 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -1,3 +1,4 @@ +import { OutbreakData } from "../../../domain/entities/alert/AlertData"; import { DataSource, DiseaseOutbreakEvent, @@ -106,6 +107,72 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, ]); } + getEventByDiseaseOrHazardType( + _filter: OutbreakData + ): FutureData { + return Future.success([ + { + id: "1", + name: "Disease Outbreak 1", + dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, + created: new Date(), + lastUpdated: new Date(), + createdByName: "createdByName", + hazardType: "Biological:Animal", + mainSyndromeCode: undefined, + suspectedDiseaseCode: undefined, + notificationSourceCode: "1", + areasAffectedDistrictIds: [], + areasAffectedProvinceIds: [], + incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + emerged: { date: new Date(), narrative: "emerged" }, + detected: { date: new Date(), narrative: "detected" }, + notified: { date: new Date(), narrative: "notified" }, + earlyResponseActions: { + initiateInvestigation: new Date(), + conductEpidemiologicalAnalysis: new Date(), + laboratoryConfirmation: { date: new Date(), na: false }, + appropriateCaseManagement: { date: new Date(), na: false }, + initiatePublicHealthCounterMeasures: { date: new Date(), na: false }, + initiateRiskCommunication: { date: new Date(), na: false }, + establishCoordination: new Date(), + responseNarrative: "responseNarrative", + }, + incidentManagerName: "incidentManager", + notes: undefined, + }, + { + id: "2", + name: "Disease Outbreak 2", + dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, + created: new Date(), + lastUpdated: new Date(), + createdByName: "createdByName2", + hazardType: "Biological:Human", + mainSyndromeCode: "2", + suspectedDiseaseCode: undefined, + notificationSourceCode: "2", + areasAffectedDistrictIds: [], + areasAffectedProvinceIds: [], + incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + emerged: { date: new Date(), narrative: "emerged" }, + detected: { date: new Date(), narrative: "detected" }, + notified: { date: new Date(), narrative: "notified" }, + earlyResponseActions: { + initiateInvestigation: new Date(), + conductEpidemiologicalAnalysis: new Date(), + laboratoryConfirmation: { date: new Date(), na: false }, + appropriateCaseManagement: { date: new Date(), na: false }, + initiatePublicHealthCounterMeasures: { date: new Date(), na: false }, + initiateRiskCommunication: { date: new Date(), na: false }, + establishCoordination: new Date(), + responseNarrative: "responseNarrative", + }, + incidentManagerName: "incidentManager", + notes: undefined, + }, + ]); + } save(_diseaseOutbreak: DiseaseOutbreakEvent): FutureData { return Future.success(""); } diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index 0ee08d1b..c666c700 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -1,47 +1,24 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { DataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { AlertOptions } from "../../../domain/repositories/AlertRepository"; -import { Maybe } from "../../../utils/ts-utils"; -import { Option } from "../../../domain/entities/Ref"; import { alertOutbreakCodes } from "../consts/AlertConstants"; import { getValueFromMap } from "./DiseaseOutbreakMapper"; -import { - dataSourceMap, - diseaseOutbreakCodes, - incidentStatusMap, -} from "../consts/DiseaseOutbreakConstants"; +import { NotificationOptions } from "../../../domain/repositories/NotificationRepository"; -export function mapTrackedEntityAttributesToAlertOptions( - nationalTrackedEntity: D2TrackerTrackedEntity, - alertTrackedEntity: D2TrackerTrackedEntity -): AlertOptions { - if (!nationalTrackedEntity.trackedEntity) throw new Error("Tracked entity not found"); - - const fromDiseaseOutbreakMap = ( - key: keyof typeof diseaseOutbreakCodes, - trackedEntity: D2TrackerTrackedEntity - ) => getValueFromMap(key, trackedEntity); - - const fromAlertOutbreakMap = ( - key: keyof typeof alertOutbreakCodes, - trackedEntity: D2TrackerTrackedEntity - ) => getAlertValueFromMap(key, trackedEntity); - - const dataSource = dataSourceMap[fromDiseaseOutbreakMap("dataSource", nationalTrackedEntity)]; - const incidentStatus = - incidentStatusMap[fromDiseaseOutbreakMap("incidentStatus", nationalTrackedEntity)]; - - if (!dataSource || !incidentStatus) throw new Error("Data source or incident status not valid"); - - const diseaseOutbreak: AlertOptions = { - eventId: nationalTrackedEntity.trackedEntity, - dataSource: dataSource, - hazardTypeCode: fromAlertOutbreakMap("hazardType", alertTrackedEntity), - suspectedDiseaseCode: fromAlertOutbreakMap("suspectedDisease", alertTrackedEntity), - incidentStatus: incidentStatus, +export function mapTrackedEntityAttributesToNotificationOptions( + trackedEntity: D2TrackerTrackedEntity +): NotificationOptions { + const verificationStatus = getAlertValueFromMap("verificationStatus", trackedEntity); + const incidentManager = getAlertValueFromMap("incidentManager", trackedEntity); + const emergenceDate = getValueFromMap("emergedDate", trackedEntity); + const detectionDate = getValueFromMap("detectedDate", trackedEntity); + const notificationDate = getValueFromMap("notifiedDate", trackedEntity); + + return { + detectionDate: detectionDate, + emergenceDate: emergenceDate, + incidentManager: incidentManager, + notificationDate: notificationDate, + verificationStatus: verificationStatus, }; - - return diseaseOutbreak; } export function getAlertValueFromMap( @@ -53,24 +30,3 @@ export function getAlertValueFromMap( ?.value ?? "" ); } - -export function getOutbreakKey(options: { - dataSource: DataSource; - outbreakValue: Maybe; - hazardTypes: Option[]; - suspectedDiseases: Option[]; -}): string { - const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; - - const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; - const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; - - if (!diseaseName && !hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); - - switch (dataSource) { - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: - return hazardName ?? ""; - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: - return diseaseName ?? ""; - } -} diff --git a/src/data/repositories/utils/MetadataHelper.ts b/src/data/repositories/utils/MetadataHelper.ts index 79a6c45f..d5ffef34 100644 --- a/src/data/repositories/utils/MetadataHelper.ts +++ b/src/data/repositories/utils/MetadataHelper.ts @@ -1,8 +1,7 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { Id, Ref } from "../../../domain/entities/Ref"; +import { Id } from "../../../domain/entities/Ref"; import { D2Api } from "../../../types/d2-api"; -import { apiToFuture, FutureData } from "../../api-futures"; -import { assertOrError } from "./AssertOrError"; +import { apiToFuture } from "../../api-futures"; import { Attribute } from "@eyeseetea/d2-api/api/trackedEntityInstances"; import { Maybe } from "../../../utils/ts-utils"; @@ -26,23 +25,6 @@ export function getProgramTEAsMetadata(api: D2Api, programId: string) { ); } -export function getUserGroupByCode(api: D2Api, code: string): FutureData { - return apiToFuture( - api.metadata.get({ - userGroups: { - fields: { - id: true, - }, - filter: { - code: { eq: code }, - }, - }, - }) - ) - .flatMap(response => assertOrError(response.userGroups[0], `User group ${code}`)) - .map(userGroup => userGroup); -} - export function getTEAttributeById( trackedEntity: D2TrackerTrackedEntity, attributeId: Id diff --git a/src/data/repositories/utils/NotificationMapper.ts b/src/data/repositories/utils/NotificationMapper.ts deleted file mode 100644 index 54a03bd2..00000000 --- a/src/data/repositories/utils/NotificationMapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { getAlertValueFromMap } from "./AlertOutbreakMapper"; -import { NotificationOptions } from "../../../domain/repositories/NotificationRepository"; -import { getValueFromMap } from "./DiseaseOutbreakMapper"; - -export function getNotificationOptionsFromTrackedEntity( - alertTrackedEntity: D2TrackerTrackedEntity -): NotificationOptions { - const verificationStatus = getAlertValueFromMap("verificationStatus", alertTrackedEntity); - const incidentManager = getAlertValueFromMap("incidentManager", alertTrackedEntity); - const emergenceDate = getValueFromMap("emergedDate", alertTrackedEntity); - const detectionDate = getValueFromMap("detectedDate", alertTrackedEntity); - const notificationDate = getValueFromMap("notifiedDate", alertTrackedEntity); - - return { - detectionDate: detectionDate, - emergenceDate: emergenceDate, - incidentManager: incidentManager, - notificationDate: notificationDate, - verificationStatus: verificationStatus, - }; -} diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 31e26798..503f40fb 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -4,11 +4,13 @@ import { TrackedEntitiesGetResponse, } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Id } from "../../../domain/entities/Ref"; +import { OutbreakData } from "../../../domain/entities/alert/AlertData"; export async function getAllTrackedEntitiesAsync( api: D2Api, programId: Id, - orgUnitId: Id + orgUnitId: Id, + filter?: OutbreakData ): Promise { const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; @@ -25,12 +27,8 @@ export async function getAllTrackedEntitiesAsync( totalPages: true, page: page, pageSize: pageSize, - fields: { - attributes: true, - orgUnit: true, - trackedEntity: true, - trackedEntityType: true, - }, + fields: fields, + filter: filter ? `${filter.id}:eq:${filter.value}` : undefined, }) .getData(); @@ -43,3 +41,20 @@ export async function getAllTrackedEntitiesAsync( return []; } } + +const fields = { + attributes: true, + orgUnit: true, + trackedEntity: true, + trackedEntityType: true, + enrollments: { + events: { + createdAt: true, + dataValues: { + dataElement: true, + value: true, + }, + event: true, + }, + }, +}; diff --git a/src/domain/entities/UserGroup.ts b/src/domain/entities/UserGroup.ts new file mode 100644 index 00000000..4049db80 --- /dev/null +++ b/src/domain/entities/UserGroup.ts @@ -0,0 +1,3 @@ +import { Ref } from "./Ref"; + +export type UserGroup = Ref; diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/AlertData.ts index dade4a44..0a0660bd 100644 --- a/src/domain/entities/alert/AlertData.ts +++ b/src/domain/entities/alert/AlertData.ts @@ -1,30 +1,17 @@ import { Maybe } from "../../../utils/ts-utils"; +import { NotificationOptions } from "../../repositories/NotificationRepository"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../Ref"; import { Alert } from "./Alert"; +export type OutbreakData = { + id: Id; // disease or hazard + value: Maybe; // disease or hazard code +}; + export type AlertData = { alert: Alert; dataSource: DataSource; - outbreakData: { - id: string; - value: string; - }; -}; - -export type AlertSynchronizationData = { - lastSyncTime: string; - type: string; - nationalDiseaseOutbreakEventId: Id; - alerts: { - alertId: string; - eventDate: Maybe; - orgUnit: Maybe; - suspectedCases: string; - probableCases: string; - confirmedCases: string; - deaths: string; - }[]; -} & { - [key in "disease" | "hazard"]?: string; + outbreakData: OutbreakData; + notificationOptions: NotificationOptions; }; diff --git a/src/domain/entities/alert/AlertSynchronizationData.ts b/src/domain/entities/alert/AlertSynchronizationData.ts new file mode 100644 index 00000000..04b947f3 --- /dev/null +++ b/src/domain/entities/alert/AlertSynchronizationData.ts @@ -0,0 +1,41 @@ +import { Maybe } from "../../../utils/ts-utils"; +import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id, Option } from "../Ref"; + +export type AlertSynchronizationData = { + lastSyncTime: string; + type: string; + nationalDiseaseOutbreakEventId: Id; + alerts: { + alertId: string; + eventDate: Maybe; + orgUnit: Maybe; + suspectedCases: string; + probableCases: string; + confirmedCases: string; + deaths: string; + }[]; +} & { + [key in "disease" | "hazard"]?: string; +}; + +export function getOutbreakKey(options: { + dataSource: DataSource; + outbreakValue: Maybe; + hazardTypes: Option[]; + suspectedDiseases: Option[]; +}): string { + const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; + + const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; + const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; + + if (!diseaseName && !hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + switch (dataSource) { + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: + return hazardName ?? ""; + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: + return diseaseName ?? ""; + } +} diff --git a/src/domain/repositories/AlertDataRepository.ts b/src/domain/repositories/AlertDataRepository.ts new file mode 100644 index 00000000..b9ea96c9 --- /dev/null +++ b/src/domain/repositories/AlertDataRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { AlertData } from "../entities/alert/AlertData"; + +export interface AlertDataRepository { + get(): FutureData; +} diff --git a/src/domain/repositories/AlertRepository.ts b/src/domain/repositories/AlertRepository.ts index c0805e88..c1d136c8 100644 --- a/src/domain/repositories/AlertRepository.ts +++ b/src/domain/repositories/AlertRepository.ts @@ -14,7 +14,6 @@ export interface AlertRepository { export type AlertOptions = { dataSource: DataSource; eventId: Id; - hazardTypeCode: Maybe; + outbreakValue: Maybe; incidentStatus: IncidentStatus; - suspectedDiseaseCode: Maybe; }; diff --git a/src/domain/repositories/AlertSyncRepository.ts b/src/domain/repositories/AlertSyncRepository.ts index 8148e54c..e2e5d544 100644 --- a/src/domain/repositories/AlertSyncRepository.ts +++ b/src/domain/repositories/AlertSyncRepository.ts @@ -1,8 +1,8 @@ import { FutureData } from "../../data/api-futures"; -import { DataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { Id, Option } from "../entities/Ref"; import { Maybe } from "../../utils/ts-utils"; +import { Id, Option } from "../entities/Ref"; import { Alert } from "../entities/alert/Alert"; +import { DataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; export interface AlertSyncRepository { saveAlertSyncData(options: AlertSyncOptions): FutureData; @@ -11,9 +11,8 @@ export interface AlertSyncRepository { export type AlertSyncOptions = { alert: Alert; dataSource: DataSource; + outbreakValue: Maybe; nationalDiseaseOutbreakEventId: Id; - hazardTypeCode: Maybe; - suspectedDiseaseCode: Maybe; hazardTypes: Option[]; suspectedDiseases: Option[]; }; diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 42d3d280..49a03697 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,10 +1,14 @@ import { FutureData } from "../../data/api-futures"; +import { AttributeFilter } from "../../data/repositories/AlertD2Repository"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { ConfigLabel, Id } from "../entities/Ref"; export interface DiseaseOutbreakEventRepository { get(id: Id): FutureData; getAll(): FutureData; + getEventByDiseaseOrHazardType( + filter: AttributeFilter + ): FutureData; save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData; getConfigStrings(): FutureData; } diff --git a/src/domain/repositories/NotificationRepository.ts b/src/domain/repositories/NotificationRepository.ts index 8f36650f..468605cf 100644 --- a/src/domain/repositories/NotificationRepository.ts +++ b/src/domain/repositories/NotificationRepository.ts @@ -1,16 +1,15 @@ import { FutureData } from "../../data/api-futures"; -import { Ref } from "../entities/Ref"; +import { AlertData } from "../entities/alert/AlertData"; +import { UserGroup } from "../entities/UserGroup"; export interface NotificationRepository { - save(notification: Notification): FutureData; + notifyNationalWatchStaff( + alertData: AlertData, + outbreakName: string, + userGroups: UserGroup[] + ): FutureData; } -export type Notification = { - subject: string; - text: string; - userGroups: Ref[]; -}; - export type NotificationOptions = { detectionDate: string; emergenceDate: string; diff --git a/src/domain/repositories/UserGroupRepository.ts b/src/domain/repositories/UserGroupRepository.ts new file mode 100644 index 00000000..ac92a475 --- /dev/null +++ b/src/domain/repositories/UserGroupRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { UserGroup } from "../entities/UserGroup"; + +export interface UserGroupRepository { + getUserGroupByCode(code: string): FutureData; +} diff --git a/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts b/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts index b1aa5b3b..58efc332 100644 --- a/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts +++ b/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts @@ -37,9 +37,8 @@ export class MapDiseaseOutbreakToAlertsUseCase { .updateAlerts({ dataSource: dataSource, eventId: diseaseOutbreakEventId, - hazardTypeCode: hazardTypeCode, incidentStatus: incidentStatus, - suspectedDiseaseCode: suspectedDiseaseCode, + outbreakValue: hazardTypeCode ?? suspectedDiseaseCode, }) .flatMap((alerts: Alert[]) => Future.joinObj({ @@ -53,8 +52,7 @@ export class MapDiseaseOutbreakToAlertsUseCase { alert: alert, nationalDiseaseOutbreakEventId: diseaseOutbreakEventId, dataSource: dataSource, - hazardTypeCode: hazardTypeCode, - suspectedDiseaseCode: suspectedDiseaseCode, + outbreakValue: hazardTypeCode ?? suspectedDiseaseCode, hazardTypes: hazardTypes, suspectedDiseases: suspectedDiseases, }) diff --git a/src/domain/usecases/MappingScriptUseCase.ts b/src/domain/usecases/MappingScriptUseCase.ts new file mode 100644 index 00000000..f0228699 --- /dev/null +++ b/src/domain/usecases/MappingScriptUseCase.ts @@ -0,0 +1,214 @@ +import { Future } from "../entities/generic/Future"; +import { Option } from "../entities/Ref"; +import { AlertOptions, AlertRepository } from "../repositories/AlertRepository"; +import { AlertSyncRepository } from "../repositories/AlertSyncRepository"; +import { OptionsRepository } from "../repositories/OptionsRepository"; +import _ from "../entities/generic/Collection"; +import { + DataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { logger } from "../../utils/logger"; +import { NotificationRepository } from "../repositories/NotificationRepository"; +import { UserGroupRepository } from "../repositories/UserGroupRepository"; +import { AlertData, OutbreakData } from "../entities/alert/AlertData"; +import { AlertDataRepository } from "../repositories/AlertDataRepository"; +import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { getOutbreakKey } from "../entities/alert/AlertSynchronizationData"; + +export class MappingScriptUseCase { + constructor( + private alertRepository: AlertRepository, + private alertDataRepository: AlertDataRepository, + private alertSyncRepository: AlertSyncRepository, + private diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository, + private notificationRepository: NotificationRepository, + private optionsRepository: OptionsRepository, + private userGroupRepository: UserGroupRepository + ) {} + + public async execute(): Promise { + const { hazardTypes, suspectedDiseases } = await this.getOptions(); + const alertData = await this.alertDataRepository.get().toPromise(); + + logger.info( + `${alertData.length} event(s) found in the Zebra Alerts program with no national event id.` + ); + + return _(alertData) + .groupBy(alert => alert.dataSource) + .values() + .forEach(alertsByDataSource => { + const uniqueFilters = getUniqueFilters(alertsByDataSource); + + return uniqueFilters.forEach(filter => { + this.getDiseaseOutbreakEvents({ + id: filter.filterId, + value: filter.filterValue, + }).then(diseaseOutbreakEvents => { + this.handleAlertOutbreakMapping( + diseaseOutbreakEvents, + filter, + alertsByDataSource, + hazardTypes, + suspectedDiseases + ); + }); + }); + }); + } + + private handleAlertOutbreakMapping( + diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[], + filter: { filterId: string; filterValue: string; dataSource: DataSource }, + alertsByDataSource: AlertData[], + hazardTypes: Option[], + suspectedDiseases: Option[] + ) { + if (diseaseOutbreakEvents.length > 1) { + const outbreakKey = getOutbreakKey({ + dataSource: filter.dataSource, + outbreakValue: filter.filterValue, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + + logger.error(`More than 1 National event found for ${outbreakKey} outbreak.`); + + return undefined; + } + + return alertsByDataSource + .filter(alertData => alertData.outbreakData.value === filter.filterValue) + .forEach(alertData => { + this.processAlertData( + alertData, + diseaseOutbreakEvents, + hazardTypes, + suspectedDiseases + ); + }); + } + + private getDiseaseOutbreakEvents( + filter: OutbreakData + ): Promise { + return this.diseaseOutbreakEventRepository + .getEventByDiseaseOrHazardType(filter) + .toPromise(); + } + + private getOptions(): Promise<{ hazardTypes: Option[]; suspectedDiseases: Option[] }> { + return Future.joinObj({ + hazardTypes: this.optionsRepository.getHazardTypesByCode(), + suspectedDiseases: this.optionsRepository.getSuspectedDiseases(), + }).toPromise(); + } + + private processAlertData( + alertData: AlertData, + diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[], + hazardTypes: Option[], + suspectedDiseases: Option[] + ): Promise { + const { dataSource, outbreakData } = alertData; + + const outbreakType = + dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; + const outbreakName = getOutbreakKey({ + dataSource: dataSource, + outbreakValue: outbreakData.value, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + + if (diseaseOutbreakEvents.length === 0) { + return this.notifyNationalWatchStaff(alertData, outbreakType, outbreakName); + } + + const diseaseOutbreakEvent = diseaseOutbreakEvents[0]; + if (!diseaseOutbreakEvent) + throw new Error( + `No disease outbreak event found for ${outbreakType} type ${outbreakName}.` + ); + + return this.updateAlertData({ + alertData: alertData, + diseaseOutbreakEvent: diseaseOutbreakEvent, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + } + + private async notifyNationalWatchStaff( + alertData: AlertData, + alertOutbreakType: string, + outbreakName: string + ): Promise { + logger.debug(`There is no national event with ${outbreakName} ${alertOutbreakType} type.`); + + const userGroup = await this.userGroupRepository + .getUserGroupByCode(RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE) + .toPromise(); + + return this.notificationRepository + .notifyNationalWatchStaff(alertData, outbreakName, [userGroup]) + .toPromise() + .then(() => logger.success("Successfully notified all national watch staff.")); + } + + private async updateAlertData(options: { + alertData: AlertData; + diseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs; + hazardTypes: Option[]; + suspectedDiseases: Option[]; + }): Promise { + const { alertData, diseaseOutbreakEvent, hazardTypes, suspectedDiseases } = options; + + const alertOptions: AlertOptions = { + eventId: diseaseOutbreakEvent.id, + dataSource: diseaseOutbreakEvent.dataSource, + outbreakValue: alertData.outbreakData.value, + incidentStatus: diseaseOutbreakEvent.incidentStatus, + }; + + await this.alertRepository + .updateAlerts(alertOptions) + .toPromise() + .then(() => logger.success("Successfully updated alert.")); + + return this.alertSyncRepository + .saveAlertSyncData({ + alert: alertData.alert, + dataSource: alertOptions.dataSource, + outbreakValue: alertOptions.outbreakValue, + nationalDiseaseOutbreakEventId: alertOptions.eventId, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }) + .toPromise() + .then(() => logger.success("Successfully saved alert sync data.")); + } +} + +function getUniqueFilters(alerts: AlertData[]): { + filterId: string; + filterValue: string; + dataSource: DataSource; +}[] { + return _(alerts) + .uniqBy(filter => filter.outbreakData.value) + .map(filter => ({ + filterId: + filter.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + ? RTSL_ZEBRA_DISEASE_TEA_ID + : RTSL_ZEBRA_HAZARD_TEA_ID, + filterValue: filter.outbreakData.value ?? "", + dataSource: filter.dataSource, + })) + .value(); +} + +const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; +const RTSL_ZEBRA_HAZARD_TEA_ID = "Dzrw3Tf0ukB"; +const RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE = "RTSL_ZEBRA_NATONAL_WATCH_STAFF"; diff --git a/src/domain/usecases/NotifyWatchStaffUseCase.ts b/src/domain/usecases/NotifyWatchStaffUseCase.ts deleted file mode 100644 index 0eb47c9b..00000000 --- a/src/domain/usecases/NotifyWatchStaffUseCase.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Ref } from "../entities/Ref"; -import { - NotificationOptions, - NotificationRepository, -} from "../repositories/NotificationRepository"; -import { FutureData } from "../../data/api-futures"; - -export class NotifyWatchStaffUseCase { - constructor(private notificationRepository: NotificationRepository) {} - - public execute( - outbreakKey: string, - notificationData: NotificationOptions, - userGroups: Ref[] - ): FutureData { - return this.notificationRepository.save({ - subject: `New Outbreak Alert: ${outbreakKey} in zm Zambia Ministry of Health`, - text: buildNotificationText(outbreakKey, notificationData), - userGroups: userGroups, - }); - } -} - -function buildNotificationText(outbreakKey: string, notificationData: NotificationOptions): string { - const { detectionDate, emergenceDate, incidentManager, notificationDate, verificationStatus } = - notificationData; - - return `There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. - -Please see the details of the outbreak below: - -Emergence date: ${emergenceDate} -Detection Date : ${detectionDate} -Notification Date : ${notificationDate} -Incident Manager : ${incidentManager} -Verification Status : ${verificationStatus}`; -} diff --git a/src/scripts/common.ts b/src/scripts/common.ts index bce39725..3792b69b 100644 --- a/src/scripts/common.ts +++ b/src/scripts/common.ts @@ -21,3 +21,31 @@ export function getInstance(args: D2ApiArgs): Instance { const instance = new Instance({ url: args.url, ...args.auth }); return instance; } + +export function getApiInstanceFromEnvVariables() { + if (!process.env.VITE_DHIS2_BASE_URL) + throw new Error("VITE_DHIS2_BASE_URL must be set in the .env file"); + + if (!process.env.VITE_DHIS2_AUTH) + throw new Error("VITE_DHIS2_AUTH must be set in the .env file"); + + const username = process.env.VITE_DHIS2_AUTH.split(":")[0] ?? ""; + const password = process.env.VITE_DHIS2_AUTH.split(":")[1] ?? ""; + + if (username === "" || password === "") { + throw new Error("VITE_DHIS2_AUTH must be in the format 'username:password'"); + } + + const envVars = { + url: process.env.VITE_DHIS2_BASE_URL, + auth: { + username: username, + password: password, + }, + }; + + const api = getD2ApiFromArgs(envVars); + const instance = getInstance(envVars); + + return { api: api, instance: instance }; +} diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index b3b2c4ef..5e22bcd0 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -1,38 +1,16 @@ import { boolean, command, flag, run } from "cmd-ts"; -import { setupLogger, logger } from "../utils/logger"; +import { setupLogger } from "../utils/logger"; import path from "path"; -import { getD2ApiFromArgs, getInstance } from "./common"; -import { - RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, - RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, - RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID, - RTSL_ZEBRA_ALERTS_PROGRAM_ID, - RTSL_ZEBRA_ORG_UNIT_ID, - RTSL_ZEBRA_PROGRAM_ID, -} from "../data/repositories/consts/DiseaseOutbreakConstants"; +import { getApiInstanceFromEnvVariables } from "./common"; import _ from "../domain/entities/generic/Collection"; import { AlertD2Repository } from "../data/repositories/AlertD2Repository"; import { NotificationD2Repository } from "../data/repositories/NotificationD2Repository"; import { OptionsD2Repository } from "../data/repositories/OptionsD2Repository"; -import { Future } from "../domain/entities/generic/Future"; -import { getTEAttributeById, getUserGroupByCode } from "../data/repositories/utils/MetadataHelper"; -import { NotifyWatchStaffUseCase } from "../domain/usecases/NotifyWatchStaffUseCase"; -import { - getOutbreakKey, - mapTrackedEntityAttributesToAlertOptions, -} from "../data/repositories/utils/AlertOutbreakMapper"; import { AlertSyncDataStoreRepository } from "../data/repositories/AlertSyncDataStoreRepository"; -import { getNotificationOptionsFromTrackedEntity } from "../data/repositories/utils/NotificationMapper"; -import { AlertData } from "../domain/entities/alert/AlertData"; -import { DataSource } from "../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { Alert } from "../domain/entities/alert/Alert"; -import { Option } from "../domain/entities/Ref"; - -//TO DO : Fetch from metadata on app load -const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; -const RTSL_ZEBRA_HAZARD_TEA_ID = "Dzrw3Tf0ukB"; -const RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE = "RTSL_ZEBRA_NATONAL_WATCH_STAFF"; +import { UserGroupD2Repository } from "../data/repositories/UserGroupD2Repository"; +import { MappingScriptUseCase } from "../domain/usecases/MappingScriptUseCase"; +import { AlertDataD2Repository } from "../data/repositories/AlertDataD2Repository"; +import { DiseaseOutbreakEventD2Repository } from "../data/repositories/DiseaseOutbreakEventD2Repository"; function main() { const cmd = command({ @@ -47,277 +25,32 @@ function main() { }), }, handler: async args => { - if (!process.env.VITE_DHIS2_BASE_URL) - throw new Error("VITE_DHIS2_BASE_URL must be set in the .env file"); - - if (!process.env.VITE_DHIS2_AUTH) - throw new Error("VITE_DHIS2_AUTH must be set in the .env file"); - - const username = process.env.VITE_DHIS2_AUTH.split(":")[0] ?? ""; - const password = process.env.VITE_DHIS2_AUTH.split(":")[1] ?? ""; - - if (username === "" || password === "") { - throw new Error("VITE_DHIS2_AUTH must be in the format 'username:password'"); - } - - const envVars = { - url: process.env.VITE_DHIS2_BASE_URL, - auth: { - username: username, - password: password, - }, - }; - - const api = getD2ApiFromArgs(envVars); - const instance = getInstance(envVars); + const { api, instance } = getApiInstanceFromEnvVariables(); + await setupLogger(instance, { isDebug: args.debug }); const alertRepository = new AlertD2Repository(api); + const alertDataRepository = new AlertDataD2Repository(api); const alertSyncRepository = new AlertSyncDataStoreRepository(api); + const diseaseOutbreakEventRepository = new DiseaseOutbreakEventD2Repository(api); const notificationRepository = new NotificationD2Repository(api); const optionsRepository = new OptionsD2Repository(api); - const notifyWatchStaffUseCase = new NotifyWatchStaffUseCase(notificationRepository); - - await setupLogger(instance, { isDebug: args.debug }); - - const { hazardTypes, suspectedDiseases } = await getOptions(optionsRepository); - const alertTrackedEntities = await getAlertTrackedEntities(); - const alertsWithNoEventId = getAlertsWithNoNationalEventId(alertTrackedEntities); - logger.info( - `${alertsWithNoEventId.length} event(s) found in the Zebra Alerts program with no national event id.` + const userGroupRepository = new UserGroupD2Repository(api); + + const mappingScriptUseCase = new MappingScriptUseCase( + alertRepository, + alertDataRepository, + alertSyncRepository, + diseaseOutbreakEventRepository, + notificationRepository, + optionsRepository, + userGroupRepository ); - return _(alertsWithNoEventId) - .groupBy(alert => alert.dataSource) - .values() - .forEach(alertsByDataSource => { - const uniqueFilters = getUniqueFilters(alertsByDataSource); - - return uniqueFilters.forEach(filter => { - getNationalTrackedEntities(alertRepository, { - id: filter.filterId, - value: filter.filterValue, - }).then(nationalTrackedEntities => { - if (nationalTrackedEntities.length > 1) { - const outbreakKey = getOutbreakKey({ - dataSource: filter.dataSource, - outbreakValue: filter.filterValue, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, - }); - - logger.error( - `More than 1 National event found for ${outbreakKey} outbreak.` - ); - - return undefined; - } - - return alertsByDataSource - .filter( - alertData => alertData.outbreakData.value === filter.filterValue - ) - .forEach(alertData => { - const { alert, dataSource, outbreakData } = alertData; - - const outbreakType = - dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - ? "disease" - : "hazard"; - const outbreakName = getOutbreakKey({ - dataSource: dataSource, - outbreakValue: outbreakData.value, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, - }); - - alertSyncRepository - .getAlertTrackedEntity(alert) - .toPromise() - .then(alertTrackedEntity => { - if (nationalTrackedEntities.length === 0) { - return notifyNationalWatchStaff( - alertTrackedEntity, - outbreakType, - outbreakName - ); - } - - const nationalTrackedEntity = - nationalTrackedEntities[0]; - if (!nationalTrackedEntity) - throw new Error(`No tracked entity found.`); - - return updateAlertData({ - alert: alert, - alertTrackedEntity: alertTrackedEntity, - nationalTrackedEntity: alertTrackedEntity, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, - }); - }); - }); - }); - }); - }); - - async function getAlertTrackedEntities(): Promise { - return alertRepository.getTrackedEntitiesByTEACodeAsync({ - program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - ouMode: "DESCENDANTS", - }); - } - - async function notifyNationalWatchStaff( - alertTrackedEntity: D2TrackerTrackedEntity, - alertOutbreakType: string, - outbreakName: string - ): Promise { - logger.debug( - `There is no national event with ${outbreakName} ${alertOutbreakType} type.` - ); - - const userGroup = await getUserGroupByCode( - api, - RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE - ).toPromise(); - const notificationOptions = - getNotificationOptionsFromTrackedEntity(alertTrackedEntity); - - notifyWatchStaffUseCase - .execute(outbreakName, notificationOptions, [userGroup]) - .toPromise() - .then(() => logger.success("Successfully notified all national watch staff.")); - } - - async function updateAlertData(options: { - alert: Alert; - alertTrackedEntity: D2TrackerTrackedEntity; - nationalTrackedEntity: D2TrackerTrackedEntity; - hazardTypes: Option[]; - suspectedDiseases: Option[]; - }): Promise { - const { - alert, - alertTrackedEntity, - nationalTrackedEntity, - hazardTypes, - suspectedDiseases, - } = options; - - const alertOptions = mapTrackedEntityAttributesToAlertOptions( - nationalTrackedEntity, - alertTrackedEntity - ); - const { dataSource, eventId, hazardTypeCode, suspectedDiseaseCode } = alertOptions; - - await alertRepository - .updateAlerts(alertOptions) - .toPromise() - .then(() => logger.success("Successfully updated alert.")); - - return alertSyncRepository - .saveAlertSyncData({ - dataSource: dataSource, - hazardTypeCode: hazardTypeCode, - suspectedDiseaseCode: suspectedDiseaseCode, - nationalDiseaseOutbreakEventId: eventId, - alert: alert, - hazardTypes: hazardTypes, - suspectedDiseases: suspectedDiseases, - }) - .toPromise() - .then(() => logger.success("Successfully saved alert sync data.")); - } - - async function getNationalTrackedEntities( - alertRepository: AlertD2Repository, - filter: { - id: string; - value: string; - } - ): Promise { - return alertRepository.getTrackedEntitiesByTEACodeAsync({ - program: RTSL_ZEBRA_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - ouMode: "SELECTED", - filter: filter, - }); - } + return await mappingScriptUseCase.execute(); }, }); run(cmd, process.argv.slice(2)); } -function getOptions( - optionsRepository: OptionsD2Repository -): - | { hazardTypes: any; suspectedDiseases: any } - | PromiseLike<{ hazardTypes: any; suspectedDiseases: any }> { - return Future.joinObj({ - hazardTypes: optionsRepository.getHazardTypesByCode(), - suspectedDiseases: optionsRepository.getSuspectedDiseases(), - }).toPromise(); -} - -function getAlertsWithNoNationalEventId( - alertTrackedEntities: D2TrackerTrackedEntity[] -): AlertData[] { - return _(alertTrackedEntities) - .compactMap(trackedEntity => { - const nationalEventId = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID - ); - const hazardType = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID - ); - const diseaseType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID); - - const outbreakData = diseaseType - ? { id: diseaseType.attribute, value: diseaseType.value } - : hazardType - ? { id: hazardType.value, value: hazardType.value } - : undefined; - - if (!outbreakData) return undefined; - if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) - throw new Error("Tracked entity not found"); - - const alertData: AlertData = { - alert: { - id: trackedEntity.trackedEntity, - district: trackedEntity.orgUnit, - }, - outbreakData: outbreakData, - dataSource: diseaseType - ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, - }; - - return !nationalEventId && (hazardType || diseaseType) ? alertData : undefined; - }) - .value(); -} - -function getUniqueFilters(alerts: AlertData[]): { - filterId: string; - filterValue: string; - dataSource: DataSource; -}[] { - return _(alerts) - .uniqBy(filter => filter.outbreakData.value) - .map(filter => ({ - filterId: - filter.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - ? RTSL_ZEBRA_DISEASE_TEA_ID - : RTSL_ZEBRA_HAZARD_TEA_ID, - filterValue: filter.outbreakData.value ?? "", - dataSource: filter.dataSource, - })) - .value(); -} - main(); From c52aafea074825c519637c48d5c948cdd6dc9506 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:03:11 +0100 Subject: [PATCH 04/15] refactor: use smaller functions on alert data d2 repo --- .../repositories/AlertDataD2Repository.ts | 156 ++++++++---------- .../DiseaseOutbreakEventD2Repository.ts | 16 +- .../utils/getAllTrackedEntities.ts | 11 +- 3 files changed, 84 insertions(+), 99 deletions(-) diff --git a/src/data/repositories/AlertDataD2Repository.ts b/src/data/repositories/AlertDataD2Repository.ts index fb59344e..0623469a 100644 --- a/src/data/repositories/AlertDataD2Repository.ts +++ b/src/data/repositories/AlertDataD2Repository.ts @@ -1,10 +1,7 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; import { AlertData, OutbreakData } from "../../domain/entities/alert/AlertData"; import { AlertDataRepository } from "../../domain/repositories/AlertDataRepository"; -import { - D2TrackerTrackedEntity, - TrackedEntitiesGetResponse, -} from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { Attribute, D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, @@ -19,6 +16,9 @@ import _ from "../../domain/entities/generic/Collection"; import { getTEAttributeById } from "./utils/MetadataHelper"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { mapTrackedEntityAttributesToNotificationOptions } from "./utils/AlertOutbreakMapper"; +import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; +import { Maybe } from "../../utils/ts-utils"; +import { NotificationOptions } from "../../domain/repositories/NotificationRepository"; export class AlertDataD2Repository implements AlertDataRepository { constructor(private api: D2Api) {} @@ -34,43 +34,24 @@ export class AlertDataD2Repository implements AlertDataRepository { private getAlertData(alertTrackedEntities: D2TrackerTrackedEntity[]): FutureData { const alertsWithNoEventId = _(alertTrackedEntities) .compactMap(trackedEntity => { - const nationalEventId = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID - ); - const hazardType = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID - ); - const diseaseType = getTEAttributeById( - trackedEntity, - RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID - ); - + const { diseaseType, hazardType, nationalEventId } = + this.getAlertTEAttributes(trackedEntity); const notificationOptions = mapTrackedEntityAttributesToNotificationOptions(trackedEntity); - - const outbreakData = diseaseType - ? { id: diseaseType.attribute, value: diseaseType.value } - : hazardType - ? { id: hazardType.value, value: hazardType.value } - : undefined; + const outbreakData = this.getOutbreakData(diseaseType, hazardType); if (!outbreakData) return undefined; - if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) - throw new Error("Tracked entity not found"); - - const alertData: AlertData = { - alert: { - id: trackedEntity.trackedEntity, - district: trackedEntity.orgUnit, - }, - outbreakData: outbreakData, - dataSource: diseaseType - ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, - notificationOptions: notificationOptions, - }; + + const dataSource = diseaseType + ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; + + const alertData: AlertData = this.buildAlertData( + trackedEntity, + outbreakData, + dataSource, + notificationOptions + ); return !nationalEventId && (hazardType || diseaseType) ? alertData : undefined; }) @@ -79,57 +60,45 @@ export class AlertDataD2Repository implements AlertDataRepository { return Future.success(alertsWithNoEventId); } - private async getTrackedEntitiesByTEACodeAsync(options: { - program: Id; - orgUnit: Id; - ouMode: "SELECTED" | "DESCENDANTS"; - filter?: OutbreakData; - }): Promise { - const { program, orgUnit, ouMode, filter } = options; - const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; - - const pageSize = 250; - let page = 1; - let result: TrackedEntitiesGetResponse; - - try { - do { - result = await this.api.tracker.trackedEntities - .get({ - program: program, - orgUnit: orgUnit, - ouMode: ouMode, - totalPages: true, - page: page, - pageSize: pageSize, - fields: { - attributes: true, - orgUnit: true, - trackedEntity: true, - trackedEntityType: true, - enrollments: { - events: { - createdAt: true, - dataValues: { - dataElement: true, - value: true, - }, - event: true, - }, - }, - }, - filter: filter ? `${filter.id}:eq:${filter.value}` : undefined, - }) - .getData(); - - d2TrackerTrackedEntities.push(...result.instances); - - page++; - } while (result.page < Math.ceil((result.total as number) / pageSize)); - return d2TrackerTrackedEntities; - } catch { - return []; - } + private buildAlertData( + trackedEntity: D2TrackerTrackedEntity, + outbreakData: OutbreakData, + dataSource: DataSource, + notificationOptions: NotificationOptions + ): AlertData { + if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) + throw new Error(`Alert data not found for ${outbreakData.value}`); + + return { + alert: { + id: trackedEntity.trackedEntity, + district: trackedEntity.orgUnit, + }, + outbreakData: outbreakData, + dataSource: dataSource, + notificationOptions: notificationOptions, + }; + } + + private getOutbreakData( + diseaseType: Maybe, + hazardType: Maybe + ): Maybe { + return diseaseType + ? { id: diseaseType.attribute, value: diseaseType.value } + : hazardType + ? { id: hazardType.value, value: hazardType.value } + : undefined; + } + + private getAlertTEAttributes(trackedEntity: D2TrackerTrackedEntity) { + const nationalEventId = getTEAttributeById( + trackedEntity, + RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID + ); + const hazardType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID); + const diseaseType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID); + return { diseaseType, hazardType, nationalEventId }; } private getTrackedEntitiesByTEACode(options: { @@ -138,7 +107,16 @@ export class AlertDataD2Repository implements AlertDataRepository { ouMode: "SELECTED" | "DESCENDANTS"; filter?: OutbreakData; }): FutureData { - return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(options)); + const { program, orgUnit, ouMode, filter } = options; + + return Future.fromPromise( + getAllTrackedEntitiesAsync(this.api, { + programId: program, + orgUnitId: orgUnit, + filter: filter, + ouMode: ouMode, + }) + ); } private getAlertTrackedEntities(): FutureData { diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index dc780882..8e71b94e 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -35,7 +35,10 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep getAll(): FutureData { return Future.fromPromise( - getAllTrackedEntitiesAsync(this.api, RTSL_ZEBRA_PROGRAM_ID, RTSL_ZEBRA_ORG_UNIT_ID) + getAllTrackedEntitiesAsync(this.api, { + programId: RTSL_ZEBRA_PROGRAM_ID, + orgUnitId: RTSL_ZEBRA_ORG_UNIT_ID, + }) ).map(trackedEntities => { return trackedEntities.map(trackedEntity => { return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); @@ -47,12 +50,11 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep filter: OutbreakData ): FutureData { return Future.fromPromise( - getAllTrackedEntitiesAsync( - this.api, - RTSL_ZEBRA_PROGRAM_ID, - RTSL_ZEBRA_ORG_UNIT_ID, - filter - ) + getAllTrackedEntitiesAsync(this.api, { + programId: RTSL_ZEBRA_PROGRAM_ID, + orgUnitId: RTSL_ZEBRA_ORG_UNIT_ID, + filter: filter, + }) ).map(trackedEntities => { return trackedEntities.map(trackedEntity => { return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 503f40fb..9a3c31d4 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -8,10 +8,14 @@ import { OutbreakData } from "../../../domain/entities/alert/AlertData"; export async function getAllTrackedEntitiesAsync( api: D2Api, - programId: Id, - orgUnitId: Id, - filter?: OutbreakData + options: { + programId: Id; + orgUnitId: Id; + ouMode?: "SELECTED" | "DESCENDANTS"; + filter?: OutbreakData; + } ): Promise { + const { programId, orgUnitId, ouMode, filter } = options; const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; const pageSize = 250; @@ -24,6 +28,7 @@ export async function getAllTrackedEntitiesAsync( .get({ program: programId, orgUnit: orgUnitId, + ouMode: ouMode, totalPages: true, page: page, pageSize: pageSize, From 510a53c9d8993e66ed35596e4ebd33633e3dbad8 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:35:16 +0100 Subject: [PATCH 05/15] feat: move dhis2 ids to data layer --- src/data/repositories/AlertD2Repository.ts | 87 +++++--------- .../repositories/AlertDataD2Repository.ts | 9 +- .../DiseaseOutbreakEventD2Repository.ts | 12 +- .../utils/getAllTrackedEntities.ts | 4 +- src/domain/entities/alert/AlertData.ts | 5 +- .../DiseaseOutbreakEventRepository.ts | 4 +- ...tUseCase.ts => MapAndSaveAlertsUseCase.ts} | 113 ++++++++---------- src/scripts/mapDiseaseOutbreakToAlerts.ts | 9 +- src/utils/promiseMap.ts | 10 ++ 9 files changed, 116 insertions(+), 137 deletions(-) rename src/domain/usecases/{MappingScriptUseCase.ts => MapAndSaveAlertsUseCase.ts} (69%) create mode 100644 src/utils/promiseMap.ts diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index 2d014a7d..ce9e70ae 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -12,14 +12,12 @@ import { AlertOptions, AlertRepository } from "../../domain/repositories/AlertRe import { Id } from "../../domain/entities/Ref"; import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; -import { - D2TrackerTrackedEntity, - TrackedEntitiesGetResponse, -} from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Maybe } from "../../utils/ts-utils"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Alert } from "../../domain/entities/alert/Alert"; import { OutbreakData } from "../../domain/entities/alert/AlertData"; +import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; export class AlertD2Repository implements AlertRepository { constructor(private api: D2Api) {} @@ -74,66 +72,31 @@ export class AlertD2Repository implements AlertRepository { }); } - private async getTrackedEntitiesByTEACodeAsync(options: { + private getTrackedEntitiesByTEACode(options: { program: Id; orgUnit: Id; ouMode: "SELECTED" | "DESCENDANTS"; - filter?: OutbreakData; - }): Promise { + filter: OutbreakData; + }): FutureData { const { program, orgUnit, ouMode, filter } = options; - const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; - - const pageSize = 250; - let page = 1; - let result: TrackedEntitiesGetResponse; - - try { - do { - result = await this.api.tracker.trackedEntities - .get({ - program: program, - orgUnit: orgUnit, - ouMode: ouMode, - totalPages: true, - page: page, - pageSize: pageSize, - fields: { - attributes: true, - orgUnit: true, - trackedEntity: true, - trackedEntityType: true, - enrollments: { - events: { - createdAt: true, - dataValues: { - dataElement: true, - value: true, - }, - event: true, - }, - }, - }, - filter: filter ? `${filter.id}:eq:${filter.value}` : undefined, - }) - .getData(); - - d2TrackerTrackedEntities.push(...result.instances); - page++; - } while (result.page < Math.ceil((result.total as number) / pageSize)); - return d2TrackerTrackedEntities; - } catch { - return []; - } + return Future.fromPromise( + getAllTrackedEntitiesAsync(this.api, { + programId: program, + orgUnitId: orgUnit, + ouMode: ouMode, + filter: { + id: this.getOutbreakFilterId(filter), + value: filter.value, + }, + }) + ); } - private getTrackedEntitiesByTEACode(options: { - program: Id; - orgUnit: Id; - ouMode: "SELECTED" | "DESCENDANTS"; - filter?: OutbreakData; - }): FutureData { - return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(options)); + private getOutbreakFilterId(filter: OutbreakData): string { + return filter.type === "disease" + ? RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID + : RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID; } private getAlertOutbreakData( @@ -142,9 +105,15 @@ export class AlertD2Repository implements AlertRepository { ): OutbreakData { switch (dataSource) { case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: - return { id: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, value: outbreakValue }; + return { + type: "disease", + value: outbreakValue, + }; case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: - return { id: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, value: outbreakValue }; + return { + type: "hazard", + value: outbreakValue, + }; } } } diff --git a/src/data/repositories/AlertDataD2Repository.ts b/src/data/repositories/AlertDataD2Repository.ts index 0623469a..ba7b434a 100644 --- a/src/data/repositories/AlertDataD2Repository.ts +++ b/src/data/repositories/AlertDataD2Repository.ts @@ -85,9 +85,9 @@ export class AlertDataD2Repository implements AlertDataRepository { hazardType: Maybe ): Maybe { return diseaseType - ? { id: diseaseType.attribute, value: diseaseType.value } + ? { value: diseaseType.value, type: "disease" } : hazardType - ? { id: hazardType.value, value: hazardType.value } + ? { value: hazardType.value, type: "hazard" } : undefined; } @@ -98,6 +98,7 @@ export class AlertDataD2Repository implements AlertDataRepository { ); const hazardType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID); const diseaseType = getTEAttributeById(trackedEntity, RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID); + return { diseaseType, hazardType, nationalEventId }; } @@ -105,15 +106,13 @@ export class AlertDataD2Repository implements AlertDataRepository { program: Id; orgUnit: Id; ouMode: "SELECTED" | "DESCENDANTS"; - filter?: OutbreakData; }): FutureData { - const { program, orgUnit, ouMode, filter } = options; + const { program, orgUnit, ouMode } = options; return Future.fromPromise( getAllTrackedEntitiesAsync(this.api, { programId: program, orgUnitId: orgUnit, - filter: filter, ouMode: ouMode, }) ); diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index 8e71b94e..7237e772 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -53,7 +53,10 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep getAllTrackedEntitiesAsync(this.api, { programId: RTSL_ZEBRA_PROGRAM_ID, orgUnitId: RTSL_ZEBRA_ORG_UNIT_ID, - filter: filter, + filter: { + id: this.getOutbreakFilterId(filter), + value: filter.value, + }, }) ).map(trackedEntities => { return trackedEntities.map(trackedEntity => { @@ -62,6 +65,10 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } + private getOutbreakFilterId(filter: OutbreakData): string { + return filter.type === "disease" ? RTSL_ZEBRA_DISEASE_TEA_ID : RTSL_ZEBRA_HAZARD_TEA_ID; + } + save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData { return getProgramTEAsMetadata(this.api, RTSL_ZEBRA_PROGRAM_ID).flatMap( teasMetadataResponse => { @@ -108,3 +115,6 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep //TO DO : Implement delete/archive after requirement confirmation } + +const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; +const RTSL_ZEBRA_HAZARD_TEA_ID = "Dzrw3Tf0ukB"; diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 9a3c31d4..2bd9931b 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -4,7 +4,7 @@ import { TrackedEntitiesGetResponse, } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Id } from "../../../domain/entities/Ref"; -import { OutbreakData } from "../../../domain/entities/alert/AlertData"; +import { Maybe } from "../../../utils/ts-utils"; export async function getAllTrackedEntitiesAsync( api: D2Api, @@ -12,7 +12,7 @@ export async function getAllTrackedEntitiesAsync( programId: Id; orgUnitId: Id; ouMode?: "SELECTED" | "DESCENDANTS"; - filter?: OutbreakData; + filter?: { id: string; value: Maybe }; } ): Promise { const { programId, orgUnitId, ouMode, filter } = options; diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/AlertData.ts index 0a0660bd..427925f6 100644 --- a/src/domain/entities/alert/AlertData.ts +++ b/src/domain/entities/alert/AlertData.ts @@ -1,12 +1,11 @@ import { Maybe } from "../../../utils/ts-utils"; import { NotificationOptions } from "../../repositories/NotificationRepository"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; -import { Id } from "../Ref"; import { Alert } from "./Alert"; export type OutbreakData = { - id: Id; // disease or hazard - value: Maybe; // disease or hazard code + type: "disease" | "hazard"; + value: Maybe; }; export type AlertData = { diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 49a03697..4d717f58 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../data/api-futures"; -import { AttributeFilter } from "../../data/repositories/AlertD2Repository"; +import { OutbreakData } from "../entities/alert/AlertData"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { ConfigLabel, Id } from "../entities/Ref"; @@ -7,7 +7,7 @@ export interface DiseaseOutbreakEventRepository { get(id: Id): FutureData; getAll(): FutureData; getEventByDiseaseOrHazardType( - filter: AttributeFilter + filter: OutbreakData ): FutureData; save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData; getConfigStrings(): FutureData; diff --git a/src/domain/usecases/MappingScriptUseCase.ts b/src/domain/usecases/MapAndSaveAlertsUseCase.ts similarity index 69% rename from src/domain/usecases/MappingScriptUseCase.ts rename to src/domain/usecases/MapAndSaveAlertsUseCase.ts index f0228699..0303c0cb 100644 --- a/src/domain/usecases/MappingScriptUseCase.ts +++ b/src/domain/usecases/MapAndSaveAlertsUseCase.ts @@ -15,8 +15,9 @@ import { AlertData, OutbreakData } from "../entities/alert/AlertData"; import { AlertDataRepository } from "../repositories/AlertDataRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; import { getOutbreakKey } from "../entities/alert/AlertSynchronizationData"; +import { promiseMap } from "../../utils/promiseMap"; -export class MappingScriptUseCase { +export class MapAndSaveAlertsUseCase { constructor( private alertRepository: AlertRepository, private alertDataRepository: AlertDataRepository, @@ -35,66 +36,70 @@ export class MappingScriptUseCase { `${alertData.length} event(s) found in the Zebra Alerts program with no national event id.` ); - return _(alertData) + const uniqueFiltersWithAlerts = _(alertData) .groupBy(alert => alert.dataSource) .values() - .forEach(alertsByDataSource => { - const uniqueFilters = getUniqueFilters(alertsByDataSource); - - return uniqueFilters.forEach(filter => { - this.getDiseaseOutbreakEvents({ - id: filter.filterId, - value: filter.filterValue, - }).then(diseaseOutbreakEvents => { - this.handleAlertOutbreakMapping( - diseaseOutbreakEvents, - filter, - alertsByDataSource, - hazardTypes, - suspectedDiseases - ); - }); - }); + .flatMap(alertsByDataSource => { + return getUniqueFilters(alertsByDataSource).map(outbreakData => ({ + outbreakData: outbreakData, + alerts: alertsByDataSource, + })); }); + + await promiseMap(uniqueFiltersWithAlerts, async uniqueFilterWithAlerts => { + const { outbreakData, alerts } = uniqueFilterWithAlerts; + + return this.mapDiseaseOutbreakToAlertsAndSave( + alerts, + outbreakData, + hazardTypes, + suspectedDiseases + ); + }); } - private handleAlertOutbreakMapping( - diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[], - filter: { filterId: string; filterValue: string; dataSource: DataSource }, - alertsByDataSource: AlertData[], + private async mapDiseaseOutbreakToAlertsAndSave( + alertData: AlertData[], + outbreakData: OutbreakData, hazardTypes: Option[], suspectedDiseases: Option[] - ) { + ): Promise { + const diseaseOutbreakEvents = await this.getDiseaseOutbreakEvents(outbreakData); + const dataSource = + outbreakData.type === "disease" + ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS + : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; + if (diseaseOutbreakEvents.length > 1) { const outbreakKey = getOutbreakKey({ - dataSource: filter.dataSource, - outbreakValue: filter.filterValue, + dataSource: dataSource, + outbreakValue: outbreakData.value, hazardTypes: hazardTypes, suspectedDiseases: suspectedDiseases, }); - logger.error(`More than 1 National event found for ${outbreakKey} outbreak.`); - - return undefined; + return logger.error(`More than 1 National event found for ${outbreakKey} outbreak.`); } - return alertsByDataSource - .filter(alertData => alertData.outbreakData.value === filter.filterValue) - .forEach(alertData => { - this.processAlertData( - alertData, - diseaseOutbreakEvents, - hazardTypes, - suspectedDiseases - ); - }); + const outbreakAlerts = alertData.filter( + alertData => alertData.outbreakData.value === outbreakData.value + ); + + await promiseMap(outbreakAlerts, alertData => { + return this.mapAndSaveAlertData( + alertData, + diseaseOutbreakEvents, + hazardTypes, + suspectedDiseases + ); + }); } private getDiseaseOutbreakEvents( - filter: OutbreakData + outbreakData: OutbreakData ): Promise { return this.diseaseOutbreakEventRepository - .getEventByDiseaseOrHazardType(filter) + .getEventByDiseaseOrHazardType(outbreakData) .toPromise(); } @@ -105,7 +110,7 @@ export class MappingScriptUseCase { }).toPromise(); } - private processAlertData( + private mapAndSaveAlertData( alertData: AlertData, diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[], hazardTypes: Option[], @@ -113,8 +118,7 @@ export class MappingScriptUseCase { ): Promise { const { dataSource, outbreakData } = alertData; - const outbreakType = - dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; + const outbreakType = outbreakData.type; const outbreakName = getOutbreakKey({ dataSource: dataSource, outbreakValue: outbreakData.value, @@ -128,7 +132,7 @@ export class MappingScriptUseCase { const diseaseOutbreakEvent = diseaseOutbreakEvents[0]; if (!diseaseOutbreakEvent) - throw new Error( + return logger.error( `No disease outbreak event found for ${outbreakType} type ${outbreakName}.` ); @@ -191,24 +195,11 @@ export class MappingScriptUseCase { } } -function getUniqueFilters(alerts: AlertData[]): { - filterId: string; - filterValue: string; - dataSource: DataSource; -}[] { +function getUniqueFilters(alerts: AlertData[]): OutbreakData[] { return _(alerts) - .uniqBy(filter => filter.outbreakData.value) - .map(filter => ({ - filterId: - filter.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - ? RTSL_ZEBRA_DISEASE_TEA_ID - : RTSL_ZEBRA_HAZARD_TEA_ID, - filterValue: filter.outbreakData.value ?? "", - dataSource: filter.dataSource, - })) + .uniqBy(alertData => alertData.outbreakData.value) + .map(alertData => alertData.outbreakData) .value(); } -const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; -const RTSL_ZEBRA_HAZARD_TEA_ID = "Dzrw3Tf0ukB"; const RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE = "RTSL_ZEBRA_NATONAL_WATCH_STAFF"; diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index 5e22bcd0..3ca591f0 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -8,14 +8,15 @@ import { NotificationD2Repository } from "../data/repositories/NotificationD2Rep import { OptionsD2Repository } from "../data/repositories/OptionsD2Repository"; import { AlertSyncDataStoreRepository } from "../data/repositories/AlertSyncDataStoreRepository"; import { UserGroupD2Repository } from "../data/repositories/UserGroupD2Repository"; -import { MappingScriptUseCase } from "../domain/usecases/MappingScriptUseCase"; +import { MapAndSaveAlertsUseCase } from "../domain/usecases/MapAndSaveAlertsUseCase"; import { AlertDataD2Repository } from "../data/repositories/AlertDataD2Repository"; import { DiseaseOutbreakEventD2Repository } from "../data/repositories/DiseaseOutbreakEventD2Repository"; function main() { const cmd = command({ name: path.basename(__filename), - description: "Map national event ID to Zebra Alert Events with no event ID", + description: + "Map national event ID to Zebra Alert Events with no event ID, and save alert data to datastore", args: { debug: flag({ type: boolean, @@ -36,7 +37,7 @@ function main() { const optionsRepository = new OptionsD2Repository(api); const userGroupRepository = new UserGroupD2Repository(api); - const mappingScriptUseCase = new MappingScriptUseCase( + const mapAndSaveAlertsUseCase = new MapAndSaveAlertsUseCase( alertRepository, alertDataRepository, alertSyncRepository, @@ -46,7 +47,7 @@ function main() { userGroupRepository ); - return await mappingScriptUseCase.execute(); + return await mapAndSaveAlertsUseCase.execute(); }, }); diff --git a/src/utils/promiseMap.ts b/src/utils/promiseMap.ts new file mode 100644 index 00000000..517f4153 --- /dev/null +++ b/src/utils/promiseMap.ts @@ -0,0 +1,10 @@ +export function promiseMap(inputValues: T[], mapper: (value: T) => Promise): Promise { + const reducer = (acc$: Promise, inputValue: T): Promise => + acc$.then((acc: S[]) => + mapper(inputValue).then(result => { + acc.push(result); + return acc; + }) + ); + return inputValues.reduce(reducer, Promise.resolve([])); +} From 34f5483b26bfcf7cb48f2d4ef996bd9c624eaad2 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 14 Oct 2024 06:54:23 -0700 Subject: [PATCH 06/15] fix: failing test --- .../repositories/test/DiseaseOutbreakEventTestRepository.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index 1ae3b757..e96af433 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -127,7 +127,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "1", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -143,6 +143,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + status: "ACTIVE", }, { id: "2", @@ -157,7 +158,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "2", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -173,6 +174,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + status: "COMPLETED", }, ]); } From abf700bad06dd68eb714abc2d4708a30f8755df5 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:08:45 -0700 Subject: [PATCH 07/15] fix: import AlertSyncData type correctly --- src/data/repositories/PerformanceOverviewD2Repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index 44243d8d..0a86f086 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -21,8 +21,8 @@ import { PerformanceOverviewMetrics, DiseaseNames, } from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; -import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; import { OrgUnit } from "../../domain/entities/OrgUnit"; +import { AlertSynchronizationData } from "../../domain/entities/alert/AlertSynchronizationData"; const formatDate = (date: Date): string => { const year = date.getFullYear(); From 649fa131c0ead255090ed771412c85bb7f79314f Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:10:02 -0700 Subject: [PATCH 08/15] feat: use options object for repositories --- .../usecases/MapAndSaveAlertsUseCase.ts | 35 +++++++++++-------- src/scripts/mapDiseaseOutbreakToAlerts.ts | 18 +++++----- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/domain/usecases/MapAndSaveAlertsUseCase.ts b/src/domain/usecases/MapAndSaveAlertsUseCase.ts index 0303c0cb..127eeb4d 100644 --- a/src/domain/usecases/MapAndSaveAlertsUseCase.ts +++ b/src/domain/usecases/MapAndSaveAlertsUseCase.ts @@ -19,18 +19,20 @@ import { promiseMap } from "../../utils/promiseMap"; export class MapAndSaveAlertsUseCase { constructor( - private alertRepository: AlertRepository, - private alertDataRepository: AlertDataRepository, - private alertSyncRepository: AlertSyncRepository, - private diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository, - private notificationRepository: NotificationRepository, - private optionsRepository: OptionsRepository, - private userGroupRepository: UserGroupRepository + private options: { + alertRepository: AlertRepository; + alertDataRepository: AlertDataRepository; + alertSyncRepository: AlertSyncRepository; + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + notificationRepository: NotificationRepository; + optionsRepository: OptionsRepository; + userGroupRepository: UserGroupRepository; + } ) {} public async execute(): Promise { const { hazardTypes, suspectedDiseases } = await this.getOptions(); - const alertData = await this.alertDataRepository.get().toPromise(); + const alertData = await this.options.alertDataRepository.get().toPromise(); logger.info( `${alertData.length} event(s) found in the Zebra Alerts program with no national event id.` @@ -98,15 +100,17 @@ export class MapAndSaveAlertsUseCase { private getDiseaseOutbreakEvents( outbreakData: OutbreakData ): Promise { - return this.diseaseOutbreakEventRepository + return this.options.diseaseOutbreakEventRepository .getEventByDiseaseOrHazardType(outbreakData) .toPromise(); } private getOptions(): Promise<{ hazardTypes: Option[]; suspectedDiseases: Option[] }> { + const { optionsRepository } = this.options; + return Future.joinObj({ - hazardTypes: this.optionsRepository.getHazardTypesByCode(), - suspectedDiseases: this.optionsRepository.getSuspectedDiseases(), + hazardTypes: optionsRepository.getHazardTypesByCode(), + suspectedDiseases: optionsRepository.getSuspectedDiseases(), }).toPromise(); } @@ -149,13 +153,14 @@ export class MapAndSaveAlertsUseCase { alertOutbreakType: string, outbreakName: string ): Promise { + const { notificationRepository, userGroupRepository } = this.options; logger.debug(`There is no national event with ${outbreakName} ${alertOutbreakType} type.`); - const userGroup = await this.userGroupRepository + const userGroup = await userGroupRepository .getUserGroupByCode(RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE) .toPromise(); - return this.notificationRepository + return notificationRepository .notifyNationalWatchStaff(alertData, outbreakName, [userGroup]) .toPromise() .then(() => logger.success("Successfully notified all national watch staff.")); @@ -176,12 +181,12 @@ export class MapAndSaveAlertsUseCase { incidentStatus: diseaseOutbreakEvent.incidentStatus, }; - await this.alertRepository + await this.options.alertRepository .updateAlerts(alertOptions) .toPromise() .then(() => logger.success("Successfully updated alert.")); - return this.alertSyncRepository + return this.options.alertSyncRepository .saveAlertSyncData({ alert: alertData.alert, dataSource: alertOptions.dataSource, diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index 3ca591f0..d6828a2c 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -37,15 +37,15 @@ function main() { const optionsRepository = new OptionsD2Repository(api); const userGroupRepository = new UserGroupD2Repository(api); - const mapAndSaveAlertsUseCase = new MapAndSaveAlertsUseCase( - alertRepository, - alertDataRepository, - alertSyncRepository, - diseaseOutbreakEventRepository, - notificationRepository, - optionsRepository, - userGroupRepository - ); + const mapAndSaveAlertsUseCase = new MapAndSaveAlertsUseCase({ + alertRepository: alertRepository, + alertDataRepository: alertDataRepository, + alertSyncRepository: alertSyncRepository, + diseaseOutbreakEventRepository: diseaseOutbreakEventRepository, + notificationRepository: notificationRepository, + optionsRepository: optionsRepository, + userGroupRepository: userGroupRepository, + }); return await mapAndSaveAlertsUseCase.execute(); }, From df2d1ea4b0a6840e364e1ac411ba21982e82ca3c Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Fri, 18 Oct 2024 08:45:48 -0700 Subject: [PATCH 09/15] refactor: use ts record instead of ternary, clean archi fixes --- src/data/repositories/AlertD2Repository.ts | 37 ++++++++++-------- .../DiseaseOutbreakEventD2Repository.ts | 15 ++++++-- .../repositories/NotificationD2Repository.ts | 10 +++-- ...sitory.ts => OutbreakAlertD2Repository.ts} | 38 +++++++++++-------- .../DiseaseOutbreakEventTestRepository.ts | 2 +- .../utils/getAllTrackedEntities.ts | 2 +- .../alert/AlertSynchronizationData.ts | 6 ++- .../alert/{AlertData.ts => OutbreakAlert.ts} | 6 ++- .../repositories/AlertDataRepository.ts | 6 --- src/domain/repositories/AlertRepository.ts | 4 +- .../DiseaseOutbreakEventRepository.ts | 2 +- .../repositories/NotificationRepository.ts | 4 +- .../repositories/OutbreakAlertRepository.ts | 6 +++ .../usecases/MapAndSaveAlertsUseCase.ts | 32 +++++++++------- .../MapDiseaseOutbreakToAlertsUseCase.ts | 6 ++- src/scripts/mapDiseaseOutbreakToAlerts.ts | 6 +-- 16 files changed, 109 insertions(+), 73 deletions(-) rename src/data/repositories/{AlertDataD2Repository.ts => OutbreakAlertD2Repository.ts} (77%) rename src/domain/entities/alert/{AlertData.ts => OutbreakAlert.ts} (79%) delete mode 100644 src/domain/repositories/AlertDataRepository.ts create mode 100644 src/domain/repositories/OutbreakAlertRepository.ts diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index ce9e70ae..d2c92287 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -16,7 +16,7 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEnti import { Maybe } from "../../utils/ts-utils"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Alert } from "../../domain/entities/alert/Alert"; -import { OutbreakData } from "../../domain/entities/alert/AlertData"; +import { OutbreakData, OutbreakDataType } from "../../domain/entities/alert/OutbreakAlert"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; export class AlertD2Repository implements AlertRepository { @@ -94,26 +94,31 @@ export class AlertD2Repository implements AlertRepository { } private getOutbreakFilterId(filter: OutbreakData): string { - return filter.type === "disease" - ? RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID - : RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID; + const mapping: Record = { + disease: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, + hazard: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, + }; + + return mapping[filter.type]; } private getAlertOutbreakData( dataSource: DataSource, outbreakValue: Maybe ): OutbreakData { - switch (dataSource) { - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: - return { - type: "disease", - value: outbreakValue, - }; - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: - return { - type: "hazard", - value: outbreakValue, - }; - } + const mapping: Record = { + [DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS]: { + type: "disease", + value: outbreakValue, + }, + [DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS]: { + type: "hazard", + value: outbreakValue, + }, + }; + + return mapping[dataSource]; } } + +type TrackedEntityAttributeId = Id; diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index 3beb84fe..b99a2040 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -13,7 +13,7 @@ import { getProgramTEAsMetadata } from "./utils/MetadataHelper"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; -import { OutbreakData } from "../../domain/entities/alert/AlertData"; +import { OutbreakData, OutbreakDataType } from "../../domain/entities/alert/OutbreakAlert"; export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRepository { constructor(private api: D2Api) {} @@ -56,7 +56,7 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep programId: RTSL_ZEBRA_PROGRAM_ID, orgUnitId: RTSL_ZEBRA_ORG_UNIT_ID, filter: { - id: this.getOutbreakFilterId(filter), + id: this.getDiseaseOutbreakFilterId(filter), value: filter.value, }, }) @@ -67,8 +67,13 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } - private getOutbreakFilterId(filter: OutbreakData): string { - return filter.type === "disease" ? RTSL_ZEBRA_DISEASE_TEA_ID : RTSL_ZEBRA_HAZARD_TEA_ID; + private getDiseaseOutbreakFilterId(filter: OutbreakData): string { + const mapping: Record = { + disease: RTSL_ZEBRA_DISEASE_TEA_ID, + hazard: RTSL_ZEBRA_HAZARD_TEA_ID, + }; + + return mapping[filter.type]; } save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData { @@ -120,3 +125,5 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; const RTSL_ZEBRA_HAZARD_TEA_ID = "Dzrw3Tf0ukB"; + +type TrackedEntityAttributeId = Id; diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index ed33aea6..3a76ad8c 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -6,12 +6,14 @@ import { import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { UserGroup } from "../../domain/entities/UserGroup"; -import { AlertData } from "../../domain/entities/alert/AlertData"; +import { OutbreakAlert } from "../../domain/entities/alert/OutbreakAlert"; +import i18n from "../../utils/i18n"; + export class NotificationD2Repository implements NotificationRepository { constructor(private api: D2Api) {} notifyNationalWatchStaff( - alertData: AlertData, + alertData: OutbreakAlert, outbreakName: string, userGroups: UserGroup[] ): FutureData { @@ -31,7 +33,7 @@ function buildNotificationText(outbreakKey: string, notificationData: Notificati const { detectionDate, emergenceDate, incidentManager, notificationDate, verificationStatus } = notificationData; - return `There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. + return i18n.t(`There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. Please see the details of the outbreak below: @@ -39,5 +41,5 @@ Emergence date: ${emergenceDate} Detection Date : ${detectionDate} Notification Date : ${notificationDate} Incident Manager : ${incidentManager} -Verification Status : ${verificationStatus}`; +Verification Status : ${verificationStatus}`); } diff --git a/src/data/repositories/AlertDataD2Repository.ts b/src/data/repositories/OutbreakAlertD2Repository.ts similarity index 77% rename from src/data/repositories/AlertDataD2Repository.ts rename to src/data/repositories/OutbreakAlertD2Repository.ts index ba7b434a..5b5ad6c9 100644 --- a/src/data/repositories/AlertDataD2Repository.ts +++ b/src/data/repositories/OutbreakAlertD2Repository.ts @@ -1,6 +1,6 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; -import { AlertData, OutbreakData } from "../../domain/entities/alert/AlertData"; -import { AlertDataRepository } from "../../domain/repositories/AlertDataRepository"; +import { OutbreakAlert, OutbreakData } from "../../domain/entities/alert/OutbreakAlert"; +import { OutbreakAlertRepository } from "../../domain/repositories/OutbreakAlertRepository"; import { Attribute, D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, @@ -20,18 +20,17 @@ import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; import { Maybe } from "../../utils/ts-utils"; import { NotificationOptions } from "../../domain/repositories/NotificationRepository"; -export class AlertDataD2Repository implements AlertDataRepository { +export class OutbreakAlertD2Repository implements OutbreakAlertRepository { constructor(private api: D2Api) {} - get(): FutureData { - return this.getAlertTrackedEntities().flatMap(alertTEIs => { - const alertsWithNoEventId = this.getAlertData(alertTEIs); - - return alertsWithNoEventId; + get(): FutureData { + return this.getAlertTrackedEntities().map(alertTEIs => { + return this.getOutbreakAlerts(alertTEIs); }); } - private getAlertData(alertTrackedEntities: D2TrackerTrackedEntity[]): FutureData { + private getOutbreakAlerts(alertTrackedEntities: D2TrackerTrackedEntity[]): OutbreakAlert[] { + // these are alerts that have no national event id const alertsWithNoEventId = _(alertTrackedEntities) .compactMap(trackedEntity => { const { diseaseType, hazardType, nationalEventId } = @@ -42,11 +41,10 @@ export class AlertDataD2Repository implements AlertDataRepository { if (!outbreakData) return undefined; - const dataSource = diseaseType - ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; + const dataSource = this.getAlertDataSource(diseaseType, hazardType); + if (!dataSource) return undefined; - const alertData: AlertData = this.buildAlertData( + const alertData: OutbreakAlert = this.buildAlertData( trackedEntity, outbreakData, dataSource, @@ -57,7 +55,16 @@ export class AlertDataD2Repository implements AlertDataRepository { }) .value(); - return Future.success(alertsWithNoEventId); + return alertsWithNoEventId; + } + + private getAlertDataSource( + diseaseType: Maybe, + hazardType: Maybe + ): Maybe { + if (diseaseType) return DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS; + else if (hazardType) return DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; + else return undefined; } private buildAlertData( @@ -65,7 +72,7 @@ export class AlertDataD2Repository implements AlertDataRepository { outbreakData: OutbreakData, dataSource: DataSource, notificationOptions: NotificationOptions - ): AlertData { + ): OutbreakAlert { if (!trackedEntity.trackedEntity || !trackedEntity.orgUnit) throw new Error(`Alert data not found for ${outbreakData.value}`); @@ -84,6 +91,7 @@ export class AlertDataD2Repository implements AlertDataRepository { diseaseType: Maybe, hazardType: Maybe ): Maybe { + // use a full mapping (record/switch) return diseaseType ? { value: diseaseType.value, type: "disease" } : hazardType diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index e96af433..220c7780 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -1,4 +1,4 @@ -import { OutbreakData } from "../../../domain/entities/alert/AlertData"; +import { OutbreakData } from "../../../domain/entities/alert/OutbreakAlert"; import { DataSource, DiseaseOutbreakEvent, diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 70ef43dd..add6a7b8 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -63,4 +63,4 @@ const fields = { event: true, }, }, -}; +} as const; diff --git a/src/domain/entities/alert/AlertSynchronizationData.ts b/src/domain/entities/alert/AlertSynchronizationData.ts index 04b947f3..573885d9 100644 --- a/src/domain/entities/alert/AlertSynchronizationData.ts +++ b/src/domain/entities/alert/AlertSynchronizationData.ts @@ -1,6 +1,8 @@ import { Maybe } from "../../../utils/ts-utils"; +import { OutbreakValueCode } from "../../repositories/AlertRepository"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; import { Id, Option } from "../Ref"; +import { OutbreakDataType } from "./OutbreakAlert"; export type AlertSynchronizationData = { lastSyncTime: string; @@ -16,12 +18,12 @@ export type AlertSynchronizationData = { deaths: string; }[]; } & { - [key in "disease" | "hazard"]?: string; + [key in OutbreakDataType]?: string; }; export function getOutbreakKey(options: { dataSource: DataSource; - outbreakValue: Maybe; + outbreakValue: Maybe; hazardTypes: Option[]; suspectedDiseases: Option[]; }): string { diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/OutbreakAlert.ts similarity index 79% rename from src/domain/entities/alert/AlertData.ts rename to src/domain/entities/alert/OutbreakAlert.ts index 427925f6..4221fbf7 100644 --- a/src/domain/entities/alert/AlertData.ts +++ b/src/domain/entities/alert/OutbreakAlert.ts @@ -3,12 +3,14 @@ import { NotificationOptions } from "../../repositories/NotificationRepository"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; import { Alert } from "./Alert"; +export type OutbreakDataType = "disease" | "hazard"; + export type OutbreakData = { - type: "disease" | "hazard"; + type: OutbreakDataType; value: Maybe; }; -export type AlertData = { +export type OutbreakAlert = { alert: Alert; dataSource: DataSource; outbreakData: OutbreakData; diff --git a/src/domain/repositories/AlertDataRepository.ts b/src/domain/repositories/AlertDataRepository.ts deleted file mode 100644 index b9ea96c9..00000000 --- a/src/domain/repositories/AlertDataRepository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { AlertData } from "../entities/alert/AlertData"; - -export interface AlertDataRepository { - get(): FutureData; -} diff --git a/src/domain/repositories/AlertRepository.ts b/src/domain/repositories/AlertRepository.ts index d3c14a16..93fd6685 100644 --- a/src/domain/repositories/AlertRepository.ts +++ b/src/domain/repositories/AlertRepository.ts @@ -11,9 +11,11 @@ export interface AlertRepository { updateAlerts(alertOptions: AlertOptions): FutureData; } +export type OutbreakValueCode = string; + export type AlertOptions = { dataSource: DataSource; eventId: Id; - outbreakValue: Maybe; + outbreakValue: Maybe; incidentStatus: NationalIncidentStatus; }; diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 4d717f58..5759095d 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../data/api-futures"; -import { OutbreakData } from "../entities/alert/AlertData"; +import { OutbreakData } from "../entities/alert/OutbreakAlert"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { ConfigLabel, Id } from "../entities/Ref"; diff --git a/src/domain/repositories/NotificationRepository.ts b/src/domain/repositories/NotificationRepository.ts index 468605cf..bcd6b52c 100644 --- a/src/domain/repositories/NotificationRepository.ts +++ b/src/domain/repositories/NotificationRepository.ts @@ -1,10 +1,10 @@ import { FutureData } from "../../data/api-futures"; -import { AlertData } from "../entities/alert/AlertData"; +import { OutbreakAlert } from "../entities/alert/OutbreakAlert"; import { UserGroup } from "../entities/UserGroup"; export interface NotificationRepository { notifyNationalWatchStaff( - alertData: AlertData, + alertData: OutbreakAlert, outbreakName: string, userGroups: UserGroup[] ): FutureData; diff --git a/src/domain/repositories/OutbreakAlertRepository.ts b/src/domain/repositories/OutbreakAlertRepository.ts new file mode 100644 index 00000000..3965fc6d --- /dev/null +++ b/src/domain/repositories/OutbreakAlertRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { OutbreakAlert } from "../entities/alert/OutbreakAlert"; + +export interface OutbreakAlertRepository { + get(): FutureData; +} diff --git a/src/domain/usecases/MapAndSaveAlertsUseCase.ts b/src/domain/usecases/MapAndSaveAlertsUseCase.ts index 127eeb4d..0b24dce4 100644 --- a/src/domain/usecases/MapAndSaveAlertsUseCase.ts +++ b/src/domain/usecases/MapAndSaveAlertsUseCase.ts @@ -11,8 +11,8 @@ import { import { logger } from "../../utils/logger"; import { NotificationRepository } from "../repositories/NotificationRepository"; import { UserGroupRepository } from "../repositories/UserGroupRepository"; -import { AlertData, OutbreakData } from "../entities/alert/AlertData"; -import { AlertDataRepository } from "../repositories/AlertDataRepository"; +import { OutbreakAlert, OutbreakData, OutbreakDataType } from "../entities/alert/OutbreakAlert"; +import { OutbreakAlertRepository } from "../repositories/OutbreakAlertRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; import { getOutbreakKey } from "../entities/alert/AlertSynchronizationData"; import { promiseMap } from "../../utils/promiseMap"; @@ -21,7 +21,7 @@ export class MapAndSaveAlertsUseCase { constructor( private options: { alertRepository: AlertRepository; - alertDataRepository: AlertDataRepository; + outbreakAlertRepository: OutbreakAlertRepository; alertSyncRepository: AlertSyncRepository; diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; notificationRepository: NotificationRepository; @@ -32,7 +32,7 @@ export class MapAndSaveAlertsUseCase { public async execute(): Promise { const { hazardTypes, suspectedDiseases } = await this.getOptions(); - const alertData = await this.options.alertDataRepository.get().toPromise(); + const alertData = await this.options.outbreakAlertRepository.get().toPromise(); logger.info( `${alertData.length} event(s) found in the Zebra Alerts program with no national event id.` @@ -61,16 +61,13 @@ export class MapAndSaveAlertsUseCase { } private async mapDiseaseOutbreakToAlertsAndSave( - alertData: AlertData[], + alertData: OutbreakAlert[], outbreakData: OutbreakData, hazardTypes: Option[], suspectedDiseases: Option[] ): Promise { const diseaseOutbreakEvents = await this.getDiseaseOutbreakEvents(outbreakData); - const dataSource = - outbreakData.type === "disease" - ? DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS - : DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; + const dataSource = this.getDataSource(outbreakData); if (diseaseOutbreakEvents.length > 1) { const outbreakKey = getOutbreakKey({ @@ -97,6 +94,15 @@ export class MapAndSaveAlertsUseCase { }); } + private getDataSource(outbreakData: OutbreakData): DataSource { + const mapping: Record = { + disease: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, + hazard: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, + }; + + return mapping[outbreakData.type]; + } + private getDiseaseOutbreakEvents( outbreakData: OutbreakData ): Promise { @@ -115,7 +121,7 @@ export class MapAndSaveAlertsUseCase { } private mapAndSaveAlertData( - alertData: AlertData, + alertData: OutbreakAlert, diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[], hazardTypes: Option[], suspectedDiseases: Option[] @@ -149,7 +155,7 @@ export class MapAndSaveAlertsUseCase { } private async notifyNationalWatchStaff( - alertData: AlertData, + alertData: OutbreakAlert, alertOutbreakType: string, outbreakName: string ): Promise { @@ -167,7 +173,7 @@ export class MapAndSaveAlertsUseCase { } private async updateAlertData(options: { - alertData: AlertData; + alertData: OutbreakAlert; diseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs; hazardTypes: Option[]; suspectedDiseases: Option[]; @@ -200,7 +206,7 @@ export class MapAndSaveAlertsUseCase { } } -function getUniqueFilters(alerts: AlertData[]): OutbreakData[] { +function getUniqueFilters(alerts: OutbreakAlert[]): OutbreakData[] { return _(alerts) .uniqBy(alertData => alertData.outbreakData.value) .map(alertData => alertData.outbreakData) diff --git a/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts b/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts index 58efc332..aca61c98 100644 --- a/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts +++ b/src/domain/usecases/MapDiseaseOutbreakToAlertsUseCase.ts @@ -33,12 +33,14 @@ export class MapDiseaseOutbreakToAlertsUseCase { if (!diseaseOutbreakEventId) return Future.error(new Error("Disease Outbreak Event Id is required")); + const outbreakValue = hazardTypeCode ?? suspectedDiseaseCode; + return this.alertRepository .updateAlerts({ dataSource: dataSource, eventId: diseaseOutbreakEventId, incidentStatus: incidentStatus, - outbreakValue: hazardTypeCode ?? suspectedDiseaseCode, + outbreakValue: outbreakValue, }) .flatMap((alerts: Alert[]) => Future.joinObj({ @@ -52,7 +54,7 @@ export class MapDiseaseOutbreakToAlertsUseCase { alert: alert, nationalDiseaseOutbreakEventId: diseaseOutbreakEventId, dataSource: dataSource, - outbreakValue: hazardTypeCode ?? suspectedDiseaseCode, + outbreakValue: outbreakValue, hazardTypes: hazardTypes, suspectedDiseases: suspectedDiseases, }) diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index d6828a2c..1606e01f 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -9,7 +9,7 @@ import { OptionsD2Repository } from "../data/repositories/OptionsD2Repository"; import { AlertSyncDataStoreRepository } from "../data/repositories/AlertSyncDataStoreRepository"; import { UserGroupD2Repository } from "../data/repositories/UserGroupD2Repository"; import { MapAndSaveAlertsUseCase } from "../domain/usecases/MapAndSaveAlertsUseCase"; -import { AlertDataD2Repository } from "../data/repositories/AlertDataD2Repository"; +import { OutbreakAlertD2Repository } from "../data/repositories/OutbreakAlertD2Repository"; import { DiseaseOutbreakEventD2Repository } from "../data/repositories/DiseaseOutbreakEventD2Repository"; function main() { @@ -30,16 +30,16 @@ function main() { await setupLogger(instance, { isDebug: args.debug }); const alertRepository = new AlertD2Repository(api); - const alertDataRepository = new AlertDataD2Repository(api); const alertSyncRepository = new AlertSyncDataStoreRepository(api); const diseaseOutbreakEventRepository = new DiseaseOutbreakEventD2Repository(api); const notificationRepository = new NotificationD2Repository(api); const optionsRepository = new OptionsD2Repository(api); + const outbreakAlertRepository = new OutbreakAlertD2Repository(api); const userGroupRepository = new UserGroupD2Repository(api); const mapAndSaveAlertsUseCase = new MapAndSaveAlertsUseCase({ alertRepository: alertRepository, - alertDataRepository: alertDataRepository, + outbreakAlertRepository: outbreakAlertRepository, alertSyncRepository: alertSyncRepository, diseaseOutbreakEventRepository: diseaseOutbreakEventRepository, notificationRepository: notificationRepository, From 8ac3279e4e986c86723015b86d4ff95d0b43306a Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 5 Nov 2024 23:05:37 +0100 Subject: [PATCH 10/15] refactor: move static outbreak mappings move to root of file or to data repository util --- src/data/repositories/AlertD2Repository.ts | 28 ++++--------------- .../repositories/utils/AlertOutbreakMapper.ts | 19 +++++++++++++ .../usecases/MapAndSaveAlertsUseCase.ts | 10 +++---- src/scripts/common.ts | 5 ++-- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index d2c92287..06ed231d 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -1,8 +1,6 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; import { apiToFuture, FutureData } from "../api-futures"; import { - RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, - RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID, RTSL_ZEBRA_ALERTS_NATIONAL_INCIDENT_STATUS_TEA_ID, RTSL_ZEBRA_ALERTS_PROGRAM_ID, @@ -16,8 +14,9 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEnti import { Maybe } from "../../utils/ts-utils"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Alert } from "../../domain/entities/alert/Alert"; -import { OutbreakData, OutbreakDataType } from "../../domain/entities/alert/OutbreakAlert"; +import { OutbreakData } from "../../domain/entities/alert/OutbreakAlert"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; +import { outbreakDataSourceMapping, outbreakTEAMapping } from "./utils/AlertOutbreakMapper"; export class AlertD2Repository implements AlertRepository { constructor(private api: D2Api) {} @@ -94,31 +93,16 @@ export class AlertD2Repository implements AlertRepository { } private getOutbreakFilterId(filter: OutbreakData): string { - const mapping: Record = { - disease: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, - hazard: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, - }; - - return mapping[filter.type]; + return outbreakTEAMapping[filter.type]; } private getAlertOutbreakData( dataSource: DataSource, outbreakValue: Maybe ): OutbreakData { - const mapping: Record = { - [DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS]: { - type: "disease", - value: outbreakValue, - }, - [DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS]: { - type: "hazard", - value: outbreakValue, - }, + return { + type: outbreakDataSourceMapping[dataSource], + value: outbreakValue, }; - - return mapping[dataSource]; } } - -type TrackedEntityAttributeId = Id; diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index c666c700..60097e56 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -2,6 +2,13 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEnti import { alertOutbreakCodes } from "../consts/AlertConstants"; import { getValueFromMap } from "./DiseaseOutbreakMapper"; import { NotificationOptions } from "../../../domain/repositories/NotificationRepository"; +import { OutbreakDataType } from "../../../domain/entities/alert/OutbreakAlert"; +import { Id } from "../../../domain/entities/Ref"; +import { + RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, + RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, +} from "../consts/DiseaseOutbreakConstants"; +import { DataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; export function mapTrackedEntityAttributesToNotificationOptions( trackedEntity: D2TrackerTrackedEntity @@ -30,3 +37,15 @@ export function getAlertValueFromMap( ?.value ?? "" ); } + +export const outbreakTEAMapping: Record = { + disease: RTSL_ZEBRA_ALERTS_DISEASE_TEA_ID, + hazard: RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, +}; + +export const outbreakDataSourceMapping: Record = { + [DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS]: "disease", + [DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS]: "hazard", +}; + +type TrackedEntityAttributeId = Id; diff --git a/src/domain/usecases/MapAndSaveAlertsUseCase.ts b/src/domain/usecases/MapAndSaveAlertsUseCase.ts index 0b24dce4..7b38f221 100644 --- a/src/domain/usecases/MapAndSaveAlertsUseCase.ts +++ b/src/domain/usecases/MapAndSaveAlertsUseCase.ts @@ -95,11 +95,6 @@ export class MapAndSaveAlertsUseCase { } private getDataSource(outbreakData: OutbreakData): DataSource { - const mapping: Record = { - disease: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, - hazard: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, - }; - return mapping[outbreakData.type]; } @@ -213,4 +208,9 @@ function getUniqueFilters(alerts: OutbreakAlert[]): OutbreakData[] { .value(); } +const mapping: Record = { + disease: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, + hazard: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, +}; + const RTSL_ZEBRA_NATIONAL_WATCH_STAFF_USER_GROUP_CODE = "RTSL_ZEBRA_NATONAL_WATCH_STAFF"; diff --git a/src/scripts/common.ts b/src/scripts/common.ts index 3792b69b..80b82111 100644 --- a/src/scripts/common.ts +++ b/src/scripts/common.ts @@ -29,10 +29,9 @@ export function getApiInstanceFromEnvVariables() { if (!process.env.VITE_DHIS2_AUTH) throw new Error("VITE_DHIS2_AUTH must be set in the .env file"); - const username = process.env.VITE_DHIS2_AUTH.split(":")[0] ?? ""; - const password = process.env.VITE_DHIS2_AUTH.split(":")[1] ?? ""; + const [username, password] = process.env.VITE_DHIS2_AUTH.split(":"); - if (username === "" || password === "") { + if (!username || !password) { throw new Error("VITE_DHIS2_AUTH must be in the format 'username:password'"); } From fb29557688029c6f9a4cc6a0ed8ac78cd01f0435 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 6 Nov 2024 11:32:26 +0100 Subject: [PATCH 11/15] fix: error on executing script --- src/domain/entities/risk-assessment/RiskAssessmentGrading.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts index b450c739..576889d4 100644 --- a/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts +++ b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts @@ -1,4 +1,4 @@ -import i18n from "@eyeseetea/feedback-component/locales"; +import i18n from "../../../utils/i18n"; import { Code, Ref } from "../Ref"; import { Struct } from "../generic/Struct"; import { Either } from "../generic/Either"; From 1ad92f51f4d54ecb07614d0a1f596175be2d9c1b Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 17 Dec 2024 13:27:00 +0100 Subject: [PATCH 12/15] feat: use verification status in notification (not code) --- src/data/repositories/NotificationD2Repository.ts | 11 +++++++++-- src/data/repositories/consts/AlertConstants.ts | 8 ++++++++ src/data/repositories/utils/AlertOutbreakMapper.ts | 6 +++++- src/domain/entities/alert/Alert.ts | 8 ++++++++ src/domain/repositories/NotificationRepository.ts | 3 ++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index 3a76ad8c..a0a7dbc2 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -8,6 +8,7 @@ import { Future } from "../../domain/entities/generic/Future"; import { UserGroup } from "../../domain/entities/UserGroup"; import { OutbreakAlert } from "../../domain/entities/alert/OutbreakAlert"; import i18n from "../../utils/i18n"; +import { verificationStatusCodeMap } from "./consts/AlertConstants"; export class NotificationD2Repository implements NotificationRepository { constructor(private api: D2Api) {} @@ -30,8 +31,14 @@ export class NotificationD2Repository implements NotificationRepository { } function buildNotificationText(outbreakKey: string, notificationData: NotificationOptions): string { - const { detectionDate, emergenceDate, incidentManager, notificationDate, verificationStatus } = - notificationData; + const { + detectionDate, + emergenceDate, + incidentManager, + notificationDate, + verificationStatus: verificationStatusCode, + } = notificationData; + const verificationStatus = verificationStatusCodeMap[verificationStatusCode] ?? ""; return i18n.t(`There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. diff --git a/src/data/repositories/consts/AlertConstants.ts b/src/data/repositories/consts/AlertConstants.ts index fee438dd..aa9258bb 100644 --- a/src/data/repositories/consts/AlertConstants.ts +++ b/src/data/repositories/consts/AlertConstants.ts @@ -1,6 +1,14 @@ +import { AlertVerificationStatus } from "../../../domain/entities/alert/Alert"; + export const alertOutbreakCodes = { hazardType: "RTSL_ZEB_TEA_EVENT_TYPE", suspectedDisease: "RTSL_ZEB_TEA_DISEASE", verificationStatus: "RTSL_ZEB_TEA_VERIFICATION_STATUS", incidentManager: "RTSL_ZEB_TEA_ ALERT_IM_NAME", } as const; + +export const verificationStatusCodeMap: Record = { + RTSL_ZEB_AL_OS_VERIFICATION_VERIFIED: "Verified ", + RTSL_ZEB_AL_OS_VERIFICATION_PENDING_VERIFICATION: "Pending Verification", + RTSL_ZEB_AL_OS_VERIFICATION_NOT_AN_EVENT: "Not an event", +} as const; diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index 60097e56..5a1c660f 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -9,11 +9,15 @@ import { RTSL_ZEBRA_ALERTS_EVENT_TYPE_TEA_ID, } from "../consts/DiseaseOutbreakConstants"; import { DataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { AlertVerificationStatus } from "../../../domain/entities/alert/Alert"; export function mapTrackedEntityAttributesToNotificationOptions( trackedEntity: D2TrackerTrackedEntity ): NotificationOptions { - const verificationStatus = getAlertValueFromMap("verificationStatus", trackedEntity); + const verificationStatus = getAlertValueFromMap( + "verificationStatus", + trackedEntity + ) as AlertVerificationStatus; const incidentManager = getAlertValueFromMap("incidentManager", trackedEntity); const emergenceDate = getValueFromMap("emergedDate", trackedEntity); const detectionDate = getValueFromMap("detectedDate", trackedEntity); diff --git a/src/domain/entities/alert/Alert.ts b/src/domain/entities/alert/Alert.ts index a992b01b..42ec338e 100644 --- a/src/domain/entities/alert/Alert.ts +++ b/src/domain/entities/alert/Alert.ts @@ -8,3 +8,11 @@ export enum VerificationStatus { RTSL_ZEB_AL_OS_VERIFICATION_PENDING_VERIFICATION = "RTSL_ZEB_AL_OS_VERIFICATION_PENDING_VERIFICATION", RTSL_ZEB_AL_OS_VERIFICATION_NOT_AN_EVENT = "RTSL_ZEB_AL_OS_VERIFICATION_NOT_AN_EVENT", } + +export const alertVerificationStates = [ + "Verified ", + "Pending Verification", + "Not an event", +] as const; + +export type AlertVerificationStatus = (typeof alertVerificationStates)[number]; diff --git a/src/domain/repositories/NotificationRepository.ts b/src/domain/repositories/NotificationRepository.ts index bcd6b52c..e23ea28f 100644 --- a/src/domain/repositories/NotificationRepository.ts +++ b/src/domain/repositories/NotificationRepository.ts @@ -1,4 +1,5 @@ import { FutureData } from "../../data/api-futures"; +import { AlertVerificationStatus } from "../entities/alert/Alert"; import { OutbreakAlert } from "../entities/alert/OutbreakAlert"; import { UserGroup } from "../entities/UserGroup"; @@ -15,5 +16,5 @@ export type NotificationOptions = { emergenceDate: string; incidentManager: string; notificationDate: string; - verificationStatus: string; + verificationStatus: AlertVerificationStatus; }; From ef648d30724e10f78c161c3bc3b9fc9748d239a8 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:19:21 +0100 Subject: [PATCH 13/15] feat: include outbreak and ems IDs and district name in notification --- .../repositories/NotificationD2Repository.ts | 46 +++++++++++++++---- .../repositories/consts/AlertConstants.ts | 2 + .../repositories/utils/AlertOutbreakMapper.ts | 4 ++ .../repositories/NotificationRepository.ts | 2 + 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index a0a7dbc2..e5896562 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -9,6 +9,7 @@ import { UserGroup } from "../../domain/entities/UserGroup"; import { OutbreakAlert } from "../../domain/entities/alert/OutbreakAlert"; import i18n from "../../utils/i18n"; import { verificationStatusCodeMap } from "./consts/AlertConstants"; +import { assertOrError } from "./utils/AssertOrError"; export class NotificationD2Repository implements NotificationRepository { constructor(private api: D2Api) {} @@ -18,20 +19,47 @@ export class NotificationD2Repository implements NotificationRepository { outbreakName: string, userGroups: UserGroup[] ): FutureData { - const { notificationOptions } = alertData; + const { alert, notificationOptions } = alertData; + return this.getDistrictName(alert.district).flatMap(districtName => { + return apiToFuture( + this.api.messageConversations.post({ + subject: `New Outbreak Alert: ${outbreakName} in ${districtName}`, + text: buildNotificationText(outbreakName, districtName, notificationOptions), + userGroups: userGroups, + }) + ).flatMap(() => Future.success(undefined)); + }); + } + + private getDistrictName(districtId: string): FutureData { return apiToFuture( - this.api.messageConversations.post({ - subject: `New Outbreak Alert: ${outbreakName} in zm Zambia Ministry of Health`, - text: buildNotificationText(outbreakName, notificationOptions), - userGroups: userGroups, + this.api.metadata.get({ + organisationUnits: { + fields: { + name: true, + }, + filter: { + id: { + eq: districtId, + }, + }, + }, }) - ).flatMap(() => Future.success(undefined)); + ) + .flatMap(response => assertOrError(response.organisationUnits[0], "Organisation Unit")) + .map(district => district.name); } } -function buildNotificationText(outbreakKey: string, notificationData: NotificationOptions): string { +function buildNotificationText( + outbreakKey: string, + district: string, + notificationData: NotificationOptions +): string { const { + emsId, + outbreakId, detectionDate, emergenceDate, incidentManager, @@ -40,10 +68,12 @@ function buildNotificationText(outbreakKey: string, notificationData: Notificati } = notificationData; const verificationStatus = verificationStatusCodeMap[verificationStatusCode] ?? ""; - return i18n.t(`There has been a new Outbreak detected for ${outbreakKey} in zm Zambia Ministry of Health. + return i18n.t(`There has been a new Outbreak detected for ${outbreakKey} in ${district}. Please see the details of the outbreak below: +EMS ID: ${emsId} +Outbreak ID: ${outbreakId} Emergence date: ${emergenceDate} Detection Date : ${detectionDate} Notification Date : ${notificationDate} diff --git a/src/data/repositories/consts/AlertConstants.ts b/src/data/repositories/consts/AlertConstants.ts index aa9258bb..0e6b6524 100644 --- a/src/data/repositories/consts/AlertConstants.ts +++ b/src/data/repositories/consts/AlertConstants.ts @@ -5,6 +5,8 @@ export const alertOutbreakCodes = { suspectedDisease: "RTSL_ZEB_TEA_DISEASE", verificationStatus: "RTSL_ZEB_TEA_VERIFICATION_STATUS", incidentManager: "RTSL_ZEB_TEA_ ALERT_IM_NAME", + outbreakId: "RTSL_ZEB_TEA_ OutBreak_ID", + emsId: "RTSL_ZEB_TEA_EMS_ID", } as const; export const verificationStatusCodeMap: Record = { diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index 5a1c660f..8cf2d453 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -22,6 +22,8 @@ export function mapTrackedEntityAttributesToNotificationOptions( const emergenceDate = getValueFromMap("emergedDate", trackedEntity); const detectionDate = getValueFromMap("detectedDate", trackedEntity); const notificationDate = getValueFromMap("notifiedDate", trackedEntity); + const emsId = getAlertValueFromMap("emsId", trackedEntity); + const outbreakId = getAlertValueFromMap("outbreakId", trackedEntity); return { detectionDate: detectionDate, @@ -29,6 +31,8 @@ export function mapTrackedEntityAttributesToNotificationOptions( incidentManager: incidentManager, notificationDate: notificationDate, verificationStatus: verificationStatus, + emsId: emsId, + outbreakId: outbreakId, }; } diff --git a/src/domain/repositories/NotificationRepository.ts b/src/domain/repositories/NotificationRepository.ts index e23ea28f..828971bf 100644 --- a/src/domain/repositories/NotificationRepository.ts +++ b/src/domain/repositories/NotificationRepository.ts @@ -14,7 +14,9 @@ export interface NotificationRepository { export type NotificationOptions = { detectionDate: string; emergenceDate: string; + emsId: string; incidentManager: string; notificationDate: string; + outbreakId: string; verificationStatus: AlertVerificationStatus; }; From 97a53cd81948086b3b7bf1d2835f2600fb500d79 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:22:02 +0100 Subject: [PATCH 14/15] feat: send notification for only districts/outbreaks that haven't been notified --- .../repositories/NotificationD2Repository.ts | 63 ++++++++++++++++--- .../repositories/OutbreakAlertD2Repository.ts | 14 +++-- src/domain/entities/alert/OutbreakAlert.ts | 5 ++ 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index e5896562..f3af0510 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -6,13 +6,20 @@ import { import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { UserGroup } from "../../domain/entities/UserGroup"; -import { OutbreakAlert } from "../../domain/entities/alert/OutbreakAlert"; +import { NotifiedAlert, OutbreakAlert } from "../../domain/entities/alert/OutbreakAlert"; import i18n from "../../utils/i18n"; import { verificationStatusCodeMap } from "./consts/AlertConstants"; import { assertOrError } from "./utils/AssertOrError"; +import { DataStoreClient } from "../DataStoreClient"; + +const notifiedAlertsDatastoreKey = "notified-alerts"; export class NotificationD2Repository implements NotificationRepository { - constructor(private api: D2Api) {} + private dataStoreClient: DataStoreClient; + + constructor(private api: D2Api) { + this.dataStoreClient = new DataStoreClient(api); + } notifyNationalWatchStaff( alertData: OutbreakAlert, @@ -21,14 +28,37 @@ export class NotificationD2Repository implements NotificationRepository { ): FutureData { const { alert, notificationOptions } = alertData; - return this.getDistrictName(alert.district).flatMap(districtName => { - return apiToFuture( - this.api.messageConversations.post({ - subject: `New Outbreak Alert: ${outbreakName} in ${districtName}`, - text: buildNotificationText(outbreakName, districtName, notificationOptions), - userGroups: userGroups, - }) - ).flatMap(() => Future.success(undefined)); + return this.getNotifiedAlertsFromDataStore().flatMap(notifiedAlerts => { + const notifiedAlertExists = notifiedAlerts.some( + notifiedAlert => + notifiedAlert.outbreak === outbreakName && + notifiedAlert.district === alert.district + ); + if (notifiedAlertExists) { + return Future.success(undefined); + } + + const updatedNotifiedAlerts = [ + ...notifiedAlerts, + { district: alert.district, outbreak: outbreakName }, + ]; + + return Future.joinObj({ + districtName: this.getDistrictName(alert.district), + saveNotification: this.saveNotifiedAlertsToDataStore(updatedNotifiedAlerts), + }).flatMap(({ districtName }) => { + return apiToFuture( + this.api.messageConversations.post({ + subject: `New Outbreak Alert: ${outbreakName} in ${districtName}`, + text: buildNotificationText( + outbreakName, + districtName, + notificationOptions + ), + userGroups: userGroups, + }) + ).flatMap(() => Future.success(undefined)); + }); }); } @@ -50,6 +80,19 @@ export class NotificationD2Repository implements NotificationRepository { .flatMap(response => assertOrError(response.organisationUnits[0], "Organisation Unit")) .map(district => district.name); } + + private getNotifiedAlertsFromDataStore(): FutureData { + return this.dataStoreClient + .getObject(notifiedAlertsDatastoreKey) + .map(notifiedAlerts => notifiedAlerts ?? []); + } + + private saveNotifiedAlertsToDataStore(notifiedAlerts: NotifiedAlert[]): FutureData { + return this.dataStoreClient.saveObject( + notifiedAlertsDatastoreKey, + notifiedAlerts + ); + } } function buildNotificationText( diff --git a/src/data/repositories/OutbreakAlertD2Repository.ts b/src/data/repositories/OutbreakAlertD2Repository.ts index 5b5ad6c9..0b017b28 100644 --- a/src/data/repositories/OutbreakAlertD2Repository.ts +++ b/src/data/repositories/OutbreakAlertD2Repository.ts @@ -91,12 +91,14 @@ export class OutbreakAlertD2Repository implements OutbreakAlertRepository { diseaseType: Maybe, hazardType: Maybe ): Maybe { - // use a full mapping (record/switch) - return diseaseType - ? { value: diseaseType.value, type: "disease" } - : hazardType - ? { value: hazardType.value, type: "hazard" } - : undefined; + switch (true) { + case !!diseaseType: + return { value: diseaseType.value, type: "disease" }; + case !!hazardType: + return { value: hazardType.value, type: "hazard" }; + default: + return undefined; + } } private getAlertTEAttributes(trackedEntity: D2TrackerTrackedEntity) { diff --git a/src/domain/entities/alert/OutbreakAlert.ts b/src/domain/entities/alert/OutbreakAlert.ts index 4221fbf7..bce66414 100644 --- a/src/domain/entities/alert/OutbreakAlert.ts +++ b/src/domain/entities/alert/OutbreakAlert.ts @@ -16,3 +16,8 @@ export type OutbreakAlert = { outbreakData: OutbreakData; notificationOptions: NotificationOptions; }; + +export type NotifiedAlert = { + district: string; + outbreak: string; +}; From ccedbda3eeeee330c5dbc57858ca447f89f68f5e Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Fri, 20 Dec 2024 08:41:43 +0100 Subject: [PATCH 15/15] refactor: rename parameters in buildNotificationText function --- src/data/repositories/NotificationD2Repository.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts index f3af0510..65f778f9 100644 --- a/src/data/repositories/NotificationD2Repository.ts +++ b/src/data/repositories/NotificationD2Repository.ts @@ -96,8 +96,8 @@ export class NotificationD2Repository implements NotificationRepository { } function buildNotificationText( - outbreakKey: string, - district: string, + outbreakName: string, + districtName: string, notificationData: NotificationOptions ): string { const { @@ -111,7 +111,7 @@ function buildNotificationText( } = notificationData; const verificationStatus = verificationStatusCodeMap[verificationStatusCode] ?? ""; - return i18n.t(`There has been a new Outbreak detected for ${outbreakKey} in ${district}. + return i18n.t(`There has been a new Outbreak detected for ${outbreakName} in ${districtName}. Please see the details of the outbreak below: