From 0114adb8aa95a024728d5729446f591f71a4081d Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Fri, 3 Jan 2025 06:41:33 +0000 Subject: [PATCH 01/16] Retrieve event datavalues --- src/data/DataElementsD2Repository.ts | 30 +++- src/data/OrgUnitD2Repository.ts | 26 ++++ src/domain/entities/DataElement.ts | 4 +- .../repositories/DataElementsRepository.ts | 2 + src/domain/repositories/OrgUnitRepository.ts | 1 + .../CopyProgramStageDataValuesUseCase.ts | 139 ++++++++++++++++++ src/scripts/commands/programs.ts | 66 ++++++++- 7 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 src/domain/usecases/CopyProgramStageDataValuesUseCase.ts diff --git a/src/data/DataElementsD2Repository.ts b/src/data/DataElementsD2Repository.ts index 063beca3..bb4cd35a 100644 --- a/src/data/DataElementsD2Repository.ts +++ b/src/data/DataElementsD2Repository.ts @@ -1,23 +1,31 @@ import _ from "lodash"; -import { D2Api, Id } from "types/d2-api"; +import { D2Api, Id, MetadataPick } from "types/d2-api"; import { NamedRef } from "domain/entities/Base"; import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; +import { DataElement } from "domain/entities/DataElement"; export class DataElementsD2Repository implements DataElementsRepository { constructor(private api: D2Api) {} + async getByIds(ids: Id[]): Promise { + return this.getDataElements(ids); + } + async getDataElementsNames(ids: Id[]): Promise { + return this.getDataElements(ids).then(dataElements => + dataElements.map(de => ({ id: de.id, name: de.name })) + ); + } + + private async getDataElements(ids: Id[]): Promise { const metadata$ = this.api.metadata.get({ dataElements: { - fields: { - id: true, - name: true, - }, + fields: dataElementFields, filter: { id: { in: ids } }, }, }); - const { dataElements } = await metadata$.getData(); + const dataElements = (await metadata$.getData()).dataElements; const dataElementsIds = dataElements.map(de => de.id); const dataElementsIdsNotFound = _.difference(ids, dataElementsIds); @@ -28,3 +36,13 @@ export class DataElementsD2Repository implements DataElementsRepository { } } } + +const dataElementFields = { + id: true, + name: true, + valueType: true, +} as const; + +type D2DataElement = MetadataPick<{ + dataElements: { fields: typeof dataElementFields }; +}>["dataElements"][number]; diff --git a/src/data/OrgUnitD2Repository.ts b/src/data/OrgUnitD2Repository.ts index e17b4bf3..b42dca96 100644 --- a/src/data/OrgUnitD2Repository.ts +++ b/src/data/OrgUnitD2Repository.ts @@ -8,6 +8,32 @@ import { Identifiable } from "domain/entities/Base"; export class OrgUnitD2Repository implements OrgUnitRepository { constructor(private api: D2Api) {} + async getRoot(): Promise { + const response = await this.api.metadata + .get({ + organisationUnits: { + fields: { + id: true, + code: true, + name: true, + }, + filter: { + level: { + eq: "1", + }, + }, + }, + }) + .getData(); + + const rootOrgUnit = response.organisationUnits[0]; + if (!rootOrgUnit) { + throw new Error("Root org unit not found"); + } + + return rootOrgUnit; + } + async getByIdentifiables(values: Identifiable[]): Promise { return this.getOrgUnits(values); } diff --git a/src/domain/entities/DataElement.ts b/src/domain/entities/DataElement.ts index bd0a5f91..3fce0ec9 100644 --- a/src/domain/entities/DataElement.ts +++ b/src/domain/entities/DataElement.ts @@ -1,9 +1,7 @@ import { Id } from "./Base"; -import { Translation } from "./Translation"; export interface DataElement { id: Id; name: string; - formName: string; - translations: Translation[]; + valueType: string; } diff --git a/src/domain/repositories/DataElementsRepository.ts b/src/domain/repositories/DataElementsRepository.ts index 9154872a..73f78c96 100644 --- a/src/domain/repositories/DataElementsRepository.ts +++ b/src/domain/repositories/DataElementsRepository.ts @@ -1,5 +1,7 @@ import { Id, NamedRef } from "domain/entities/Base"; +import { DataElement } from "domain/entities/DataElement"; export interface DataElementsRepository { + getByIds(ids: Id[]): Promise; getDataElementsNames(ids: Id[]): Promise; } diff --git a/src/domain/repositories/OrgUnitRepository.ts b/src/domain/repositories/OrgUnitRepository.ts index 6c7b5bc9..a1b23c46 100644 --- a/src/domain/repositories/OrgUnitRepository.ts +++ b/src/domain/repositories/OrgUnitRepository.ts @@ -3,4 +3,5 @@ import { OrgUnit } from "domain/entities/OrgUnit"; export interface OrgUnitRepository { getByIdentifiables(ids: Identifiable[]): Promise; + getRoot(): Promise; } diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts new file mode 100644 index 00000000..ac411e4b --- /dev/null +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -0,0 +1,139 @@ +import _ from "lodash"; +import { Id } from "@eyeseetea/d2-api"; +import { DataElement } from "domain/entities/DataElement"; +import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; +import { ProgramEventsRepository } from "domain/repositories/ProgramEventsRepository"; +import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; +import { EventDataValue, ProgramEvent } from "domain/entities/ProgramEvent"; + +export class CopyProgramStageDataValuesUseCase { + constructor( + private programEventsRepository: ProgramEventsRepository, + private orgUnitRepository: OrgUnitRepository, + private dataElementsRepository: DataElementsRepository + ) {} + + async execute(options: CopyProgramStageDataValuesOptions): Promise { + const { programStageId, dataElementIdPairs, post } = options; + + const rootOrgUnit = await this.orgUnitRepository.getRoot(); + const dataElements = await this.dataElementsRepository.getByIds(dataElementIdPairs.flat()); + + const dataElementPairs = dataElementIdPairs.map(([sourceId, targetId]) => + mapDataElementPair(dataElements, sourceId, targetId) + ); + + const targetBySourceId = new Map(dataElementIdPairs); + + // check each pair have the same type. + dataElementPairs.forEach(pair => validateDataElementPair(pair)); + + const events = await this.programEventsRepository.get({ + programStagesIds: [programStageId], + orgUnitsIds: [rootOrgUnit.id], + orgUnitMode: "DESCENDANTS", + }); + + console.log(events); + + // check if any data value of the destination data elements is not empty + checkNonEmptyDataValues( + events, + dataElementIdPairs.map(([_sourceId, targetId]) => targetId) + ); + + // replace origin data element id with the destination data element id + const sourceDataElementIds = dataElementIdPairs.map(([sourceId, _targetId]) => sourceId); + const eventsWithNewDataValues = events.map(event => { + return event.dataValues.flatMap(dataValue => { + if (sourceDataElementIds.includes(dataValue.dataElement.id)) { + const targetId = targetBySourceId.get(dataValue.dataElement.id); + if (!targetId) + throw new Error( + `Target data element not found for source id: ${dataValue.dataElement}` + ); + + return [{ ...dataValue, dataElement: targetId }]; + } else return []; + }); + }); + + console.log(eventsWithNewDataValues); + + // post events in chunks (if post param) + // if (post) { + // } + + // save report: Pair of data elements (ids, names), program stage (id, name), number of events updated for each pair (some data elements will not have data value yet), total of number of events updated + // save payload: events with the data values updated + } +} + +function mapDataElementPair(dataElements: DataElement[], sourceId: Id, targetId: Id): DataElementPair { + const sourceElement = dataElements.find(de => de.id === sourceId); + const targetElement = dataElements.find(de => de.id === targetId); + + if (!sourceElement || !targetElement) { + throw new Error(`Data element not found for pair: [${sourceId}, ${targetId}]`); + } + + return [sourceElement, targetElement]; +} + +function validateDataElementPair([sourceDataElement, targetDataElement]: DataElementPair) { + if (sourceDataElement.valueType !== targetDataElement.valueType) { + throw new Error( + `Data elements [${sourceDataElement.id}, ${targetDataElement.id}] do not have the same type.` + ); + } +} + +function checkNonEmptyDataValues(events: ProgramEvent[], targetDataElementIds: Id[]) { + const nonEmptyDataValues = events.flatMap(event => { + const targetDataValues = event.dataValues.filter(dataValue => + targetDataElementIds.some( + targetId => dataValue.dataElement.id === targetId && Boolean(dataValue.value) + ) + ); + + return targetDataValues.length > 0 ? [{ eventId: event.id, targetDataValues }] : []; + }); + + if (!_.isEmpty(nonEmptyDataValues)) { + throw new Error( + `Some data values of the destination data elements are not empty: \n${formatInvalidEvents( + nonEmptyDataValues + )}` + ); + } +} + +function formatInvalidEvents( + events: { + eventId: string; + targetDataValues: EventDataValue[]; + }[] +): string { + return events + .map(event => { + const values = event.targetDataValues + .map( + dataValue => + `\tTarget DataElement: ${dataValue.dataElement.id}, Value: ${JSON.stringify( + dataValue.value + )}` + ) + .join("\n"); + + return `Event ID: ${event.eventId}, Values: \n${values}`; + }) + .join("\n"); +} + +type CopyProgramStageDataValuesOptions = { + programStageId: string; + dataElementIdPairs: [Id, Id][]; // [sourceDataElementId, targetDataElementId] + post: boolean; +}; + +type DataElementPair = [DataElement, DataElement]; diff --git a/src/scripts/commands/programs.ts b/src/scripts/commands/programs.ts index 5b1f69af..fc55a6b6 100644 --- a/src/scripts/commands/programs.ts +++ b/src/scripts/commands/programs.ts @@ -1,5 +1,5 @@ import _ from "lodash"; -import { command, string, subcommands, option, positional, optional, flag } from "cmd-ts"; +import { command, string, subcommands, option, positional, optional, flag, restPositionals } from "cmd-ts"; import { choiceOf, @@ -7,6 +7,7 @@ import { getApiUrlOptions, getD2Api, getD2ApiFromArgs, + StringPairSeparatedByDash, StringsSeparatedByCommas, } from "scripts/common"; import { ProgramsD2Repository } from "data/ProgramsD2Repository"; @@ -19,6 +20,9 @@ import { DeleteProgramDataValuesUseCase } from "domain/usecases/DeleteProgramDat import { MoveProgramAttributeUseCase } from "domain/usecases/MoveProgramAttributeUseCase"; import { TrackedEntityD2Repository } from "data/TrackedEntityD2Repository"; import { DuplicatedProgramsSpreadsheetExport } from "scripts/programs/DuplicatedProgramsSpreadsheetExport"; +import { CopyProgramStageDataValuesUseCase } from "domain/usecases/CopyProgramStageDataValuesUseCase"; +import { DataElementsD2Repository } from "data/DataElementsD2Repository"; +import { OrgUnitD2Repository } from "data/OrgUnitD2Repository"; export function getCommand() { return subcommands({ @@ -30,6 +34,7 @@ export function getCommand() { "get-duplicated-events": getDuplicatedEventsCmd, "delete-data-values": deleteDataValuesCmd, "move-attribute": moveAttribute, + "copy-data-values": copyDataValuesCmd, }, }); } @@ -204,6 +209,18 @@ const getDuplicatedEventsCmd = command({ }, }); +const programIdArg = option({ + type: string, + long: "program-id", + description: "Program ID", +}); + +const programStageIdArg = option({ + type: string, + long: "program-stage-id", + description: "Program Stage ID", +}); + const moveAttribute = command({ name: "move-attribute", handler: args => { @@ -213,11 +230,7 @@ const moveAttribute = command({ }, args: { ...getApiUrlOptions(), - programId: option({ - type: string, - long: "program-id", - description: "Program ID", - }), + programId: programIdArg, fromAttributeId: option({ type: string, long: "from-attribute-id", @@ -231,6 +244,47 @@ const moveAttribute = command({ }, }); +const copyDataValuesCmd = command({ + name: "copy-data-values", + description: + "Copy data values from specific data elements to different data elements within the same tracker program's program stage", + args: { + url: getApiUrlOption(), + programStageId: programStageIdArg, + dataElementIdPairs: restPositionals({ + type: StringPairSeparatedByDash, + displayName: "ID1-ID2", + description: "Pairs of data elements IDs (origin-destination)", + }), + post: flag({ + long: "post", + description: "Post events updated with the copied data values", + }), + reportPath: option({ + type: optional(string), + long: "save-report", + description: "Save CSV report", + }), + payloadPath: option({ + type: optional(string), + long: "save-payload", + description: "Save JSON payload with each event data values", + }), + }, + handler: async args => { + const api = getD2Api(args.url); + const programEventsRepository = new ProgramEventsD2Repository(api); + const dataElementsRepository = new DataElementsD2Repository(api); + const orgUnitRepository = new OrgUnitD2Repository(api); + + new CopyProgramStageDataValuesUseCase( + programEventsRepository, + orgUnitRepository, + dataElementsRepository + ).execute(args); + }, +}); + const orgUnitsIdsArg = option({ type: StringsSeparatedByCommas, long: "org-units-ids", From fc9ce47a6c2e5f2cff3f424e20d3d87014a580fd Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Fri, 3 Jan 2025 08:01:41 +0000 Subject: [PATCH 02/16] Save report and save payload --- .../CopyProgramStageDataValuesUseCase.ts | 109 +++++++++++++----- src/scripts/commands/programs.ts | 8 +- 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index ac411e4b..f84cb116 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -1,10 +1,12 @@ import _ from "lodash"; +import fs from "fs"; import { Id } from "@eyeseetea/d2-api"; import { DataElement } from "domain/entities/DataElement"; import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; import { ProgramEventsRepository } from "domain/repositories/ProgramEventsRepository"; import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; import { EventDataValue, ProgramEvent } from "domain/entities/ProgramEvent"; +import log from "utils/log"; export class CopyProgramStageDataValuesUseCase { constructor( @@ -14,7 +16,13 @@ export class CopyProgramStageDataValuesUseCase { ) {} async execute(options: CopyProgramStageDataValuesOptions): Promise { - const { programStageId, dataElementIdPairs, post } = options; + const { + programStageId, + dataElementIdPairs, + post, + savePayload: payloadPath, + saveReport: reportPath, + } = options; const rootOrgUnit = await this.orgUnitRepository.getRoot(); const dataElements = await this.dataElementsRepository.getByIds(dataElementIdPairs.flat()); @@ -23,18 +31,22 @@ export class CopyProgramStageDataValuesUseCase { mapDataElementPair(dataElements, sourceId, targetId) ); - const targetBySourceId = new Map(dataElementIdPairs); - // check each pair have the same type. dataElementPairs.forEach(pair => validateDataElementPair(pair)); + const sourceDataElementIds = dataElementIdPairs.map(([sourceId, _targetId]) => sourceId); - const events = await this.programEventsRepository.get({ - programStagesIds: [programStageId], - orgUnitsIds: [rootOrgUnit.id], - orgUnitMode: "DESCENDANTS", - }); - - console.log(events); + const events = await this.programEventsRepository + .get({ + programStagesIds: [programStageId], + orgUnitsIds: [rootOrgUnit.id], + orgUnitMode: "DESCENDANTS", + }) + .then(events => + // filter events that have at least one data value of the source data elements + events.filter(event => + event.dataValues.some(dv => sourceDataElementIds.includes(dv.dataElement.id)) + ) + ); // check if any data value of the destination data elements is not empty checkNonEmptyDataValues( @@ -43,30 +55,67 @@ export class CopyProgramStageDataValuesUseCase { ); // replace origin data element id with the destination data element id - const sourceDataElementIds = dataElementIdPairs.map(([sourceId, _targetId]) => sourceId); const eventsWithNewDataValues = events.map(event => { - return event.dataValues.flatMap(dataValue => { - if (sourceDataElementIds.includes(dataValue.dataElement.id)) { - const targetId = targetBySourceId.get(dataValue.dataElement.id); - if (!targetId) - throw new Error( - `Target data element not found for source id: ${dataValue.dataElement}` - ); - - return [{ ...dataValue, dataElement: targetId }]; - } else return []; - }); + return { + ...event, + dataValues: event.dataValues.flatMap(dataValue => { + if (sourceDataElementIds.includes(dataValue.dataElement.id)) { + const [_source, target] = + dataElementPairs.find( + ([source, _target]) => source.id === dataValue.dataElement.id + ) || []; + + if (!target) + throw new Error( + `Target data element not found for source id: ${dataValue.dataElement.id}` + ); + + return [dataValue, { ...dataValue, dataElement: target }]; + } else return [dataValue]; + }), + }; }); - console.log(eventsWithNewDataValues); + if (payloadPath) { + const payload = { events: eventsWithNewDataValues }; + const json = JSON.stringify(payload, null, 4); + fs.writeFileSync(payloadPath, json); + log.info(`Written payload (${eventsWithNewDataValues.length} events): ${payloadPath}`); + } else if (post) { + const result = await this.programEventsRepository.save(eventsWithNewDataValues); + if (result.type === "success") log.info(JSON.stringify(result, null, 4)); + else log.error(JSON.stringify(result, null, 4)); + } + + if (reportPath) { + saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); + } + } +} - // post events in chunks (if post param) - // if (post) { - // } +function saveReport( + reportPath: string, + dataElementPairs: DataElementPair[], + programStageId: string, + eventsWithNewDataValues: ProgramEvent[] +) { + const reportLines: string[] = [ + `Program Stage ID: ${programStageId}`, + `Number of events updated: ${eventsWithNewDataValues.length}`, + "", + ]; + + const deLines = dataElementPairs.map(([source, target]) => { + const updatedEventsCount = eventsWithNewDataValues.filter(event => + event.dataValues.some(dataValue => dataValue.dataElement.id === target.id) + ).length; + + return `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name}), Found in ${updatedEventsCount} events`; + }); - // save report: Pair of data elements (ids, names), program stage (id, name), number of events updated for each pair (some data elements will not have data value yet), total of number of events updated - // save payload: events with the data values updated - } + const reportContent = reportLines.concat(deLines).join("\n"); + fs.writeFileSync(reportPath, reportContent); + log.info(`Written report: ${reportPath}`); } function mapDataElementPair(dataElements: DataElement[], sourceId: Id, targetId: Id): DataElementPair { @@ -134,6 +183,8 @@ type CopyProgramStageDataValuesOptions = { programStageId: string; dataElementIdPairs: [Id, Id][]; // [sourceDataElementId, targetDataElementId] post: boolean; + savePayload?: string; + saveReport?: string; }; type DataElementPair = [DataElement, DataElement]; diff --git a/src/scripts/commands/programs.ts b/src/scripts/commands/programs.ts index fc55a6b6..22280f03 100644 --- a/src/scripts/commands/programs.ts +++ b/src/scripts/commands/programs.ts @@ -260,15 +260,15 @@ const copyDataValuesCmd = command({ long: "post", description: "Post events updated with the copied data values", }), - reportPath: option({ + saveReport: option({ type: optional(string), long: "save-report", - description: "Save CSV report", + description: "Save TXT report", }), - payloadPath: option({ + savePayload: option({ type: optional(string), long: "save-payload", - description: "Save JSON payload with each event data values", + description: "Save JSON payload", }), }, handler: async args => { From 18ea56f265b1c5856913dd740497220d0cbbfbec Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Sun, 5 Jan 2025 09:08:30 +0000 Subject: [PATCH 03/16] Format save --- .../CopyProgramStageDataValuesUseCase.ts | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index f84cb116..0855759c 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -16,13 +16,7 @@ export class CopyProgramStageDataValuesUseCase { ) {} async execute(options: CopyProgramStageDataValuesOptions): Promise { - const { - programStageId, - dataElementIdPairs, - post, - savePayload: payloadPath, - saveReport: reportPath, - } = options; + const { programStageId, dataElementIdPairs, post, saveReport: reportPath } = options; const rootOrgUnit = await this.orgUnitRepository.getRoot(); const dataElements = await this.dataElementsRepository.getByIds(dataElementIdPairs.flat()); @@ -76,15 +70,18 @@ export class CopyProgramStageDataValuesUseCase { }; }); - if (payloadPath) { + if (post) { + const result = await this.programEventsRepository.save(eventsWithNewDataValues); + if (result.type === "success") log.info(JSON.stringify(result, null, 4)); + else log.error(JSON.stringify(result, null, 4)); + } else { const payload = { events: eventsWithNewDataValues }; const json = JSON.stringify(payload, null, 4); + const now = new Date().toISOString().slice(0, 19).replace(/:/g, "-"); + const payloadPath = `copy-program-stage-data-values-${now}.json`; + fs.writeFileSync(payloadPath, json); log.info(`Written payload (${eventsWithNewDataValues.length} events): ${payloadPath}`); - } else if (post) { - const result = await this.programEventsRepository.save(eventsWithNewDataValues); - if (result.type === "success") log.info(JSON.stringify(result, null, 4)); - else log.error(JSON.stringify(result, null, 4)); } if (reportPath) { @@ -99,21 +96,32 @@ function saveReport( programStageId: string, eventsWithNewDataValues: ProgramEvent[] ) { + const deLines = dataElementPairs.map( + ([source, target]) => + `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name})` + ); + const reportLines: string[] = [ `Program Stage ID: ${programStageId}`, - `Number of events updated: ${eventsWithNewDataValues.length}`, "", + ...deLines, + "", + `Number of events: ${eventsWithNewDataValues.length}`, + "", + ...eventsWithNewDataValues.map(event => { + const orgUnitId = event.orgUnit.id; + const eventId = event.id; + const dataValueLines = dataElementPairs.flatMap(([source, target]) => { + const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; + const status = sourceValue ? `(${sourceValue})` : undefined; + return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; + }); + + return `Event ID: ${eventId}, OrgUnit ID: ${orgUnitId}\n${dataValueLines.join("\n")}`; + }), ]; - const deLines = dataElementPairs.map(([source, target]) => { - const updatedEventsCount = eventsWithNewDataValues.filter(event => - event.dataValues.some(dataValue => dataValue.dataElement.id === target.id) - ).length; - - return `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name}), Found in ${updatedEventsCount} events`; - }); - - const reportContent = reportLines.concat(deLines).join("\n"); + const reportContent = reportLines.join("\n"); fs.writeFileSync(reportPath, reportContent); log.info(`Written report: ${reportPath}`); } @@ -183,7 +191,6 @@ type CopyProgramStageDataValuesOptions = { programStageId: string; dataElementIdPairs: [Id, Id][]; // [sourceDataElementId, targetDataElementId] post: boolean; - savePayload?: string; saveReport?: string; }; From dd31879dfea5fe9b616a4b7bf3ace694865c56f3 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Sun, 5 Jan 2025 09:59:18 +0000 Subject: [PATCH 04/16] Improve readability --- .../CopyProgramStageDataValuesUseCase.ts | 191 ++++++++---------- 1 file changed, 81 insertions(+), 110 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index 0855759c..c361e46e 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -1,12 +1,12 @@ import _ from "lodash"; import fs from "fs"; -import { Id } from "@eyeseetea/d2-api"; import { DataElement } from "domain/entities/DataElement"; import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; import { ProgramEventsRepository } from "domain/repositories/ProgramEventsRepository"; import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; import { EventDataValue, ProgramEvent } from "domain/entities/ProgramEvent"; import log from "utils/log"; +import { Id } from "domain/entities/Base"; export class CopyProgramStageDataValuesUseCase { constructor( @@ -16,44 +16,34 @@ export class CopyProgramStageDataValuesUseCase { ) {} async execute(options: CopyProgramStageDataValuesOptions): Promise { - const { programStageId, dataElementIdPairs, post, saveReport: reportPath } = options; + const { programStageId, dataElementIdPairs: idPairs, post, saveReport: reportPath } = options; const rootOrgUnit = await this.orgUnitRepository.getRoot(); - const dataElements = await this.dataElementsRepository.getByIds(dataElementIdPairs.flat()); + const dataElements = await this.dataElementsRepository.getByIds(idPairs.flat()); + const dataElementPairs = this.mapDataElements(dataElements, idPairs); + const sourceIds = idPairs.map(([sourceId, _]) => sourceId); + const targetIds = idPairs.map(([_, targetId]) => targetId); - const dataElementPairs = dataElementIdPairs.map(([sourceId, targetId]) => - mapDataElementPair(dataElements, sourceId, targetId) - ); + checkDataElementTypes(dataElementPairs); + + const allEvents = await this.programEventsRepository.get({ + programStagesIds: [programStageId], + orgUnitsIds: [rootOrgUnit.id], + orgUnitMode: "DESCENDANTS", + }); - // check each pair have the same type. - dataElementPairs.forEach(pair => validateDataElementPair(pair)); - const sourceDataElementIds = dataElementIdPairs.map(([sourceId, _targetId]) => sourceId); - - const events = await this.programEventsRepository - .get({ - programStagesIds: [programStageId], - orgUnitsIds: [rootOrgUnit.id], - orgUnitMode: "DESCENDANTS", - }) - .then(events => - // filter events that have at least one data value of the source data elements - events.filter(event => - event.dataValues.some(dv => sourceDataElementIds.includes(dv.dataElement.id)) - ) - ); - - // check if any data value of the destination data elements is not empty - checkNonEmptyDataValues( - events, - dataElementIdPairs.map(([_sourceId, targetId]) => targetId) + const applicableEvents = allEvents.filter(event => + event.dataValues.some(dv => sourceIds.includes(dv.dataElement.id)) ); + checkTargetDataValuesAreEmpty(applicableEvents, targetIds); + // replace origin data element id with the destination data element id - const eventsWithNewDataValues = events.map(event => { + const eventsWithNewDataValues = applicableEvents.map(event => { return { ...event, dataValues: event.dataValues.flatMap(dataValue => { - if (sourceDataElementIds.includes(dataValue.dataElement.id)) { + if (sourceIds.includes(dataValue.dataElement.id)) { const [_source, target] = dataElementPairs.find( ([source, _target]) => source.id === dataValue.dataElement.id @@ -85,106 +75,87 @@ export class CopyProgramStageDataValuesUseCase { } if (reportPath) { - saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); + this.saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); } } -} -function saveReport( - reportPath: string, - dataElementPairs: DataElementPair[], - programStageId: string, - eventsWithNewDataValues: ProgramEvent[] -) { - const deLines = dataElementPairs.map( - ([source, target]) => - `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name})` - ); - - const reportLines: string[] = [ - `Program Stage ID: ${programStageId}`, - "", - ...deLines, - "", - `Number of events: ${eventsWithNewDataValues.length}`, - "", - ...eventsWithNewDataValues.map(event => { - const orgUnitId = event.orgUnit.id; - const eventId = event.id; - const dataValueLines = dataElementPairs.flatMap(([source, target]) => { - const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; - const status = sourceValue ? `(${sourceValue})` : undefined; - return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; - }); - - return `Event ID: ${eventId}, OrgUnit ID: ${orgUnitId}\n${dataValueLines.join("\n")}`; - }), - ]; - - const reportContent = reportLines.join("\n"); - fs.writeFileSync(reportPath, reportContent); - log.info(`Written report: ${reportPath}`); -} + private mapDataElements(dataElements: DataElement[], pairs: [Id, Id][]): DataElementPair[] { + const dataElementPairs = pairs.map(([sourceId, targetId]) => { + const sourceElement = dataElements.find(de => de.id === sourceId); + const targetElement = dataElements.find(de => de.id === targetId); -function mapDataElementPair(dataElements: DataElement[], sourceId: Id, targetId: Id): DataElementPair { - const sourceElement = dataElements.find(de => de.id === sourceId); - const targetElement = dataElements.find(de => de.id === targetId); + if (!sourceElement || !targetElement) + return `Data element not found for pair: [${sourceId}, ${targetId}]`; + else return [sourceElement, targetElement]; + }); - if (!sourceElement || !targetElement) { - throw new Error(`Data element not found for pair: [${sourceId}, ${targetId}]`); - } + const errors = dataElementPairs.filter(pair => typeof pair === "string"); + if (!_.isEmpty(errors)) throw new Error(errors.join("\n")); - return [sourceElement, targetElement]; -} + return dataElementPairs.filter((pair): pair is DataElementPair => typeof pair !== "string"); + } -function validateDataElementPair([sourceDataElement, targetDataElement]: DataElementPair) { - if (sourceDataElement.valueType !== targetDataElement.valueType) { - throw new Error( - `Data elements [${sourceDataElement.id}, ${targetDataElement.id}] do not have the same type.` + private saveReport( + reportPath: string, + dataElementPairs: DataElementPair[], + programStageId: string, + eventsWithNewDataValues: ProgramEvent[] + ) { + const deLines = dataElementPairs.map( + ([source, target]) => + `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name})` ); + + const reportLines: string[] = [ + `Program Stage ID: ${programStageId}`, + "", + ...deLines, + "", + `Number of events: ${eventsWithNewDataValues.length}`, + "", + ...eventsWithNewDataValues.map(event => { + const orgUnitId = event.orgUnit.id; + const eventId = event.id; + const dataValueLines = dataElementPairs.flatMap(([source, target]) => { + const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; + const status = sourceValue ? `(${sourceValue})` : undefined; + return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; + }); + + return `Event ID: ${eventId}, OrgUnit ID: ${orgUnitId}\n${dataValueLines.join("\n")}`; + }), + ]; + + const reportContent = reportLines.join("\n"); + fs.writeFileSync(reportPath, reportContent); + log.info(`Written report: ${reportPath}`); } } -function checkNonEmptyDataValues(events: ProgramEvent[], targetDataElementIds: Id[]) { - const nonEmptyDataValues = events.flatMap(event => { - const targetDataValues = event.dataValues.filter(dataValue => - targetDataElementIds.some( - targetId => dataValue.dataElement.id === targetId && Boolean(dataValue.value) - ) - ); - - return targetDataValues.length > 0 ? [{ eventId: event.id, targetDataValues }] : []; - }); +function checkDataElementTypes(dePairs: DataElementPair[]) { + const typeMismatchErrors = dePairs + .filter(([source, target]) => source.valueType !== target.valueType) + .map(([source, target]) => `Data elements [${source.id}, ${target.id}] do not have the same type.`); - if (!_.isEmpty(nonEmptyDataValues)) { - throw new Error( - `Some data values of the destination data elements are not empty: \n${formatInvalidEvents( - nonEmptyDataValues - )}` - ); - } + if (!_.isEmpty(typeMismatchErrors)) throw new Error(typeMismatchErrors.join("\n")); } -function formatInvalidEvents( - events: { - eventId: string; - targetDataValues: EventDataValue[]; - }[] -): string { - return events +function checkTargetDataValuesAreEmpty(events: ProgramEvent[], targetIds: Id[]) { + const eventsWithNonEmptyTargetDataValues = _(events) .map(event => { - const values = event.targetDataValues - .map( - dataValue => - `\tTarget DataElement: ${dataValue.dataElement.id}, Value: ${JSON.stringify( - dataValue.value - )}` - ) + const nonEmpty = event.dataValues + .filter(dv => targetIds.includes(dv.dataElement.id)) + .filter(dv => Boolean(dv.value)) + .map(dv => `\tTarget DataElement: ${dv.dataElement.id}, Value: ${JSON.stringify(dv.value)}`) .join("\n"); - return `Event ID: ${event.eventId}, Values: \n${values}`; + return _.isEmpty(nonEmpty) ? undefined : `Event ID: ${event.id}, Values: \n${nonEmpty}`; }) + .compact() .join("\n"); + + const error = `Some data values of the destination data elements are not empty:\n${eventsWithNonEmptyTargetDataValues}`; + if (eventsWithNonEmptyTargetDataValues) throw new Error(error); } type CopyProgramStageDataValuesOptions = { From 1ed92f81f7f8b9e05acf94f0be09741f58621f2b Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Sun, 5 Jan 2025 10:54:04 +0000 Subject: [PATCH 05/16] Improve readability --- .../CopyProgramStageDataValuesUseCase.ts | 93 +++++++++---------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index c361e46e..cf4651c6 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -1,12 +1,12 @@ import _ from "lodash"; import fs from "fs"; +import { Id } from "domain/entities/Base"; import { DataElement } from "domain/entities/DataElement"; import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; import { ProgramEventsRepository } from "domain/repositories/ProgramEventsRepository"; import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; -import { EventDataValue, ProgramEvent } from "domain/entities/ProgramEvent"; +import { ProgramEvent } from "domain/entities/ProgramEvent"; import log from "utils/log"; -import { Id } from "domain/entities/Base"; export class CopyProgramStageDataValuesUseCase { constructor( @@ -38,27 +38,11 @@ export class CopyProgramStageDataValuesUseCase { checkTargetDataValuesAreEmpty(applicableEvents, targetIds); - // replace origin data element id with the destination data element id - const eventsWithNewDataValues = applicableEvents.map(event => { - return { - ...event, - dataValues: event.dataValues.flatMap(dataValue => { - if (sourceIds.includes(dataValue.dataElement.id)) { - const [_source, target] = - dataElementPairs.find( - ([source, _target]) => source.id === dataValue.dataElement.id - ) || []; - - if (!target) - throw new Error( - `Target data element not found for source id: ${dataValue.dataElement.id}` - ); - - return [dataValue, { ...dataValue, dataElement: target }]; - } else return [dataValue]; - }), - }; - }); + const eventsWithNewDataValues = this.copyEventDataValues( + applicableEvents, + sourceIds, + dataElementPairs + ); if (post) { const result = await this.programEventsRepository.save(eventsWithNewDataValues); @@ -79,6 +63,25 @@ export class CopyProgramStageDataValuesUseCase { } } + private copyEventDataValues( + applicableEvents: ProgramEvent[], + sourceIds: string[], + dataElementPairs: DataElementPair[] + ) { + return applicableEvents.map(event => ({ + ...event, + dataValues: event.dataValues.flatMap(dv => { + if (!sourceIds.includes(dv.dataElement.id)) return [dv]; + const target = dataElementPairs.find(([source, _]) => source.id === dv.dataElement.id)?.[1]; + + if (!target) + throw new Error(`Target data element not found for source id: ${dv.dataElement.id}`); + + return [dv, { ...dv, dataElement: target }]; + }), + })); + } + private mapDataElements(dataElements: DataElement[], pairs: [Id, Id][]): DataElementPair[] { const dataElementPairs = pairs.map(([sourceId, targetId]) => { const sourceElement = dataElements.find(de => de.id === sourceId); @@ -96,39 +99,35 @@ export class CopyProgramStageDataValuesUseCase { } private saveReport( - reportPath: string, + path: string, dataElementPairs: DataElementPair[], programStageId: string, eventsWithNewDataValues: ProgramEvent[] ) { - const deLines = dataElementPairs.map( + const dataElementLines = dataElementPairs.map( ([source, target]) => `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name})` ); - const reportLines: string[] = [ - `Program Stage ID: ${programStageId}`, - "", - ...deLines, - "", - `Number of events: ${eventsWithNewDataValues.length}`, - "", - ...eventsWithNewDataValues.map(event => { - const orgUnitId = event.orgUnit.id; - const eventId = event.id; - const dataValueLines = dataElementPairs.flatMap(([source, target]) => { - const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; - const status = sourceValue ? `(${sourceValue})` : undefined; - return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; - }); - - return `Event ID: ${eventId}, OrgUnit ID: ${orgUnitId}\n${dataValueLines.join("\n")}`; - }), - ]; + const eventLines = eventsWithNewDataValues.map(event => { + const dataValueLines = dataElementPairs.flatMap(([source, target]) => { + const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; + const status = sourceValue ? `(${sourceValue})` : undefined; + return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; + }); + + return `Event ID: ${event.id}, OrgUnit ID: ${event.orgUnit.id}\n${dataValueLines.join("\n")}`; + }); + + const content = [ + "Program Stage ID: " + programStageId, + dataElementLines.join("\n"), + "Number of events: " + eventsWithNewDataValues.length, + eventLines.join("\n"), + ].join("\n\n"); - const reportContent = reportLines.join("\n"); - fs.writeFileSync(reportPath, reportContent); - log.info(`Written report: ${reportPath}`); + fs.writeFileSync(path, content); + log.info(`Written report: ${path}`); } } From 55b18727413d6c512263f1f333bdc5d201cdf213 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Sun, 5 Jan 2025 11:32:45 +0000 Subject: [PATCH 06/16] Add tests --- .../CopyProgramStageDataValuesUseCase.ts | 14 +- .../CopyProgramStageDataValuesUseCase.data.ts | 139 ++++++++++++++++++ .../CopyProgramStageDataValuesUseCase.spec.ts | 98 ++++++++++++ 3 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.data.ts create mode 100644 src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index cf4651c6..892510b3 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -15,7 +15,7 @@ export class CopyProgramStageDataValuesUseCase { private dataElementsRepository: DataElementsRepository ) {} - async execute(options: CopyProgramStageDataValuesOptions): Promise { + async execute(options: CopyProgramStageDataValuesOptions): Promise { const { programStageId, dataElementIdPairs: idPairs, post, saveReport: reportPath } = options; const rootOrgUnit = await this.orgUnitRepository.getRoot(); @@ -61,6 +61,8 @@ export class CopyProgramStageDataValuesUseCase { if (reportPath) { this.saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); } + + return eventsWithNewDataValues; } private copyEventDataValues( @@ -77,7 +79,13 @@ export class CopyProgramStageDataValuesUseCase { if (!target) throw new Error(`Target data element not found for source id: ${dv.dataElement.id}`); - return [dv, { ...dv, dataElement: target }]; + return [ + dv, + { + ...dv, + dataElement: _.omit(target, "valueType"), + }, + ]; }), })); } @@ -157,7 +165,7 @@ function checkTargetDataValuesAreEmpty(events: ProgramEvent[], targetIds: Id[]) if (eventsWithNonEmptyTargetDataValues) throw new Error(error); } -type CopyProgramStageDataValuesOptions = { +export type CopyProgramStageDataValuesOptions = { programStageId: string; dataElementIdPairs: [Id, Id][]; // [sourceDataElementId, targetDataElementId] post: boolean; diff --git a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.data.ts b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.data.ts new file mode 100644 index 00000000..6c160919 --- /dev/null +++ b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.data.ts @@ -0,0 +1,139 @@ +import { DataElement } from "domain/entities/DataElement"; +import { ProgramEvent } from "domain/entities/ProgramEvent"; + +export const successDataElements: DataElement[] = [ + { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated", valueType: "BOOLEAN" }, + { id: "L5x9z9BAgR8", name: "Follow up Hepatitis B test indicated", valueType: "BOOLEAN" }, + { id: "N0p2yOsEy7a", name: "Hepatitis B test result", valueType: "TEXT" }, + { id: "wSCh46cADN6", name: "Follow up Hepatitis B test result", valueType: "TEXT" }, +]; + +export const mismatchedValueTypeDataElements: DataElement[] = [ + { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated", valueType: "BOOLEAN" }, + { id: "L5x9z9BAgR8", name: "Follow up Hepatitis B test indicated", valueType: "TEXT" }, + { id: "N0p2yOsEy7a", name: "Hepatitis B test result", valueType: "TEXT" }, + { id: "wSCh46cADN6", name: "Follow up Hepatitis B test result", valueType: "BOOLEAN" }, +]; + +export const missingDataElements: DataElement[] = [ + { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated", valueType: "BOOLEAN" }, + { id: "N0p2yOsEy7a", name: "Hepatitis B test result", valueType: "TEXT" }, +]; + +export const programEvents: ProgramEvent[] = [ + { + id: "gEiU1UagRO7", + program: { id: "srBYj2SwqPJ", name: "Sexual/gender-based violence v1", type: "tracker" }, + programStage: { id: "sgSKZRoWE9b", name: "Follow-up consultation" }, + orgUnit: { id: "d8cvwcaxzRu", name: "Kaya - CM CHR - SV" }, + dataValues: [ + { + dataElement: { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated" }, + value: "true", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + { + dataElement: { id: "N0p2yOsEy7a", name: "Hepatitis B test result" }, + value: "patient_refused", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + ], + created: "2025-01-02T01:11:54.237", + lastUpdated: "2025-01-02T01:12:02.611", + status: "COMPLETED", + date: "2025-01-01T00:00:00.000", + dueDate: "2025-01-02T01:12:02.610", + }, +]; + +export const expectedProgramEvents: ProgramEvent[] = [ + { + id: "gEiU1UagRO7", + program: { id: "srBYj2SwqPJ", name: "Sexual/gender-based violence v1", type: "tracker" }, + programStage: { id: "sgSKZRoWE9b", name: "Follow-up consultation" }, + orgUnit: { id: "d8cvwcaxzRu", name: "Kaya - CM CHR - SV" }, + dataValues: [ + { + dataElement: { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated" }, + value: "true", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + { + dataElement: { id: "L5x9z9BAgR8", name: "Follow up Hepatitis B test indicated" }, + value: "true", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + { + dataElement: { id: "N0p2yOsEy7a", name: "Hepatitis B test result" }, + value: "patient_refused", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + { + dataElement: { id: "wSCh46cADN6", name: "Follow up Hepatitis B test result" }, + value: "patient_refused", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + ], + created: "2025-01-02T01:11:54.237", + lastUpdated: "2025-01-02T01:12:02.611", + status: "COMPLETED", + date: "2025-01-01T00:00:00.000", + dueDate: "2025-01-02T01:12:02.610", + }, +]; + +export const nonEmptyTargetDataValuesEvents: ProgramEvent[] = [ + { + id: "gEiU1UagRO7", + program: { id: "srBYj2SwqPJ", name: "Sexual/gender-based violence v1", type: "tracker" }, + programStage: { id: "sgSKZRoWE9b", name: "Follow-up consultation" }, + orgUnit: { id: "d8cvwcaxzRu", name: "Kaya - CM CHR - SV" }, + dataValues: [ + { + dataElement: { id: "qwfxR2TQkUn", name: "Hepatitis B test indicated" }, + value: "true", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + { + dataElement: { id: "L5x9z9BAgR8", name: "Follow up Hepatitis B test indicated" }, + value: "true", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + ], + created: "2025-01-02T01:11:54.237", + lastUpdated: "2025-01-02T01:12:02.611", + status: "COMPLETED", + date: "2025-01-01T00:00:00.000", + dueDate: "2025-01-02T01:12:02.610", + }, +]; + +export const missingSourceDataValuesEvents: ProgramEvent[] = [ + { + id: "gEiU1UagRO7", + program: { id: "srBYj2SwqPJ", name: "Sexual/gender-based violence v1", type: "tracker" }, + programStage: { id: "sgSKZRoWE9b", name: "Follow-up consultation" }, + orgUnit: { id: "d8cvwcaxzRu", name: "Kaya - CM CHR - SV" }, + dataValues: [ + { + dataElement: { id: "someid", name: "somename" }, + value: "somevalue", + storedBy: "user1", + lastUpdated: "2025-01-02T01:12:02.609", + }, + ], + created: "2025-01-02T01:11:54.237", + lastUpdated: "2025-01-02T01:12:02.611", + status: "COMPLETED", + date: "2025-01-01T00:00:00.000", + dueDate: "2025-01-02T01:12:02.610", + }, +]; diff --git a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts new file mode 100644 index 00000000..899bd379 --- /dev/null +++ b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + successDataElements, + mismatchedValueTypeDataElements, + missingDataElements, + programEvents, + nonEmptyTargetDataValuesEvents, + missingSourceDataValuesEvents, + expectedProgramEvents, +} from "./CopyProgramStageDataValuesUseCase.data"; +import { + CopyProgramStageDataValuesOptions, + CopyProgramStageDataValuesUseCase, +} from "domain/usecases/CopyProgramStageDataValuesUseCase"; +import { ProgramEventsRepository } from "domain/repositories/ProgramEventsRepository"; +import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; +import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; + +describe("CopyProgramStageDataValuesUseCase", () => { + let programEventsRepository: ProgramEventsRepository; + let orgUnitRepository: OrgUnitRepository; + let dataElementsRepository: DataElementsRepository; + let useCase: CopyProgramStageDataValuesUseCase; + + beforeEach(() => { + programEventsRepository = { + get: vi.fn().mockResolvedValue(programEvents), + save: vi.fn().mockResolvedValue({ type: "success" }), + } as unknown as ProgramEventsRepository; + + orgUnitRepository = { + getRoot: vi.fn().mockResolvedValue({ id: "rootOrgUnitId" }), + } as unknown as OrgUnitRepository; + + dataElementsRepository = { + getByIds: vi.fn().mockResolvedValue(successDataElements), + } as unknown as DataElementsRepository; + + useCase = new CopyProgramStageDataValuesUseCase( + programEventsRepository, + orgUnitRepository, + dataElementsRepository + ); + }); + + it("should copy data values successfully", async () => { + const eventsWithNewDataValues = await useCase.execute({ + programStageId: "sgSKZRoWE9b", + dataElementIdPairs: [ + ["qwfxR2TQkUn", "L5x9z9BAgR8"], + ["N0p2yOsEy7a", "wSCh46cADN6"], + ], + post: true, + }); + + expect(programEventsRepository.save).toHaveBeenCalled(); + expect(eventsWithNewDataValues).toEqual(expectedProgramEvents); + }); + + it("should throw error if data element types do not match", async () => { + dataElementsRepository.getByIds = vi.fn().mockResolvedValue(mismatchedValueTypeDataElements); + + await expect(useCase.execute(commonArgs)).rejects.toThrow( + "Data elements [qwfxR2TQkUn, L5x9z9BAgR8] do not have the same type." + ); + }); + + it("should throw error if some data elements are missing", async () => { + dataElementsRepository.getByIds = vi.fn().mockResolvedValue(missingDataElements); + + await expect(useCase.execute(commonArgs)).rejects.toThrow( + "Data element not found for pair: [qwfxR2TQkUn, L5x9z9BAgR8]" + ); + }); + + it("should throw error if target data values are not empty", async () => { + programEventsRepository.get = vi.fn().mockResolvedValue(nonEmptyTargetDataValuesEvents); + + await expect(useCase.execute(commonArgs)).rejects.toThrow( + "Some data values of the destination data elements are not empty:" + ); + }); + + it("should return empty array if there is no data value with some source data element id", async () => { + programEventsRepository.get = vi.fn().mockResolvedValue(missingSourceDataValuesEvents); + + await expect(useCase.execute(commonArgs)).resolves.toEqual([]); + }); +}); + +const commonArgs: CopyProgramStageDataValuesOptions = { + programStageId: "sgSKZRoWE9b", + dataElementIdPairs: [ + ["qwfxR2TQkUn", "L5x9z9BAgR8"], + ["N0p2yOsEy7a", "wSCh46cADN6"], + ], + post: false, +}; From bada55a9a645b8170ecccf2cd1f9793b325ad003 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Sun, 5 Jan 2025 11:34:01 +0000 Subject: [PATCH 07/16] Small change: use common args --- .../__tests__/CopyProgramStageDataValuesUseCase.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts index 899bd379..3d8c629f 100644 --- a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts +++ b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts @@ -45,11 +45,7 @@ describe("CopyProgramStageDataValuesUseCase", () => { it("should copy data values successfully", async () => { const eventsWithNewDataValues = await useCase.execute({ - programStageId: "sgSKZRoWE9b", - dataElementIdPairs: [ - ["qwfxR2TQkUn", "L5x9z9BAgR8"], - ["N0p2yOsEy7a", "wSCh46cADN6"], - ], + ...commonArgs, post: true, }); From 98ab4a169a1a0147457d10c89154de3fd9986822 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:02:55 +0000 Subject: [PATCH 08/16] Remove getDataElementsNames (PR affected #18) --- src/data/DataElementsD2Repository.ts | 7 ------- src/domain/repositories/DataElementsRepository.ts | 3 +-- .../GetIndicatorsDataElementsValuesReportUseCase.ts | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/data/DataElementsD2Repository.ts b/src/data/DataElementsD2Repository.ts index bb4cd35a..47304af7 100644 --- a/src/data/DataElementsD2Repository.ts +++ b/src/data/DataElementsD2Repository.ts @@ -1,6 +1,5 @@ import _ from "lodash"; import { D2Api, Id, MetadataPick } from "types/d2-api"; -import { NamedRef } from "domain/entities/Base"; import { DataElementsRepository } from "domain/repositories/DataElementsRepository"; import { DataElement } from "domain/entities/DataElement"; @@ -11,12 +10,6 @@ export class DataElementsD2Repository implements DataElementsRepository { return this.getDataElements(ids); } - async getDataElementsNames(ids: Id[]): Promise { - return this.getDataElements(ids).then(dataElements => - dataElements.map(de => ({ id: de.id, name: de.name })) - ); - } - private async getDataElements(ids: Id[]): Promise { const metadata$ = this.api.metadata.get({ dataElements: { diff --git a/src/domain/repositories/DataElementsRepository.ts b/src/domain/repositories/DataElementsRepository.ts index 73f78c96..6c429c70 100644 --- a/src/domain/repositories/DataElementsRepository.ts +++ b/src/domain/repositories/DataElementsRepository.ts @@ -1,7 +1,6 @@ -import { Id, NamedRef } from "domain/entities/Base"; +import { Id } from "domain/entities/Base"; import { DataElement } from "domain/entities/DataElement"; export interface DataElementsRepository { getByIds(ids: Id[]): Promise; - getDataElementsNames(ids: Id[]): Promise; } diff --git a/src/domain/usecases/GetIndicatorsDataElementsValuesReportUseCase.ts b/src/domain/usecases/GetIndicatorsDataElementsValuesReportUseCase.ts index fdb2127b..2c1051c8 100644 --- a/src/domain/usecases/GetIndicatorsDataElementsValuesReportUseCase.ts +++ b/src/domain/usecases/GetIndicatorsDataElementsValuesReportUseCase.ts @@ -79,7 +79,7 @@ export class GetIndicatorsDataElementsValuesReportUseCase { ...deCheckObject.dataElements, ..._.uniq(deCheckObject.categoryOptionCombos.map(item => item.dataElement)), ]; - const dataElementsNames = await this.dataElementsRepository.getDataElementsNames(allDataElementsIds); + const dataElementsNames = await this.dataElementsRepository.getByIds(allDataElementsIds); const allCOCombosIds = [..._.uniq(deCheckObject.categoryOptionCombos.map(item => item.coCombo))]; const coCombosNames = await this.categoryOptionCombosRepository.getCOCombosNames(allCOCombosIds); From 09983472645ef0b7d541e34e86eb41e80390f0d9 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:13:26 +0000 Subject: [PATCH 09/16] Small change --- src/data/DataElementsD2Repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/DataElementsD2Repository.ts b/src/data/DataElementsD2Repository.ts index 47304af7..8a4ff4a7 100644 --- a/src/data/DataElementsD2Repository.ts +++ b/src/data/DataElementsD2Repository.ts @@ -18,7 +18,7 @@ export class DataElementsD2Repository implements DataElementsRepository { }, }); - const dataElements = (await metadata$.getData()).dataElements; + const { dataElements } = await metadata$.getData(); const dataElementsIds = dataElements.map(de => de.id); const dataElementsIdsNotFound = _.difference(ids, dataElementsIds); From c9600a1042a1bba9a9514f96c5ddcf1fe0f5ef93 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:13:47 +0000 Subject: [PATCH 10/16] Abstract private methods --- .../CopyProgramStageDataValuesUseCase.ts | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index 892510b3..d5b2d1c7 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -18,23 +18,12 @@ export class CopyProgramStageDataValuesUseCase { async execute(options: CopyProgramStageDataValuesOptions): Promise { const { programStageId, dataElementIdPairs: idPairs, post, saveReport: reportPath } = options; - const rootOrgUnit = await this.orgUnitRepository.getRoot(); - const dataElements = await this.dataElementsRepository.getByIds(idPairs.flat()); - const dataElementPairs = this.mapDataElements(dataElements, idPairs); - const sourceIds = idPairs.map(([sourceId, _]) => sourceId); - const targetIds = idPairs.map(([_, targetId]) => targetId); + const { rootOrgUnit, dataElementPairs, sourceIds, targetIds } = await this.fetchElements(idPairs); checkDataElementTypes(dataElementPairs); - const allEvents = await this.programEventsRepository.get({ - programStagesIds: [programStageId], - orgUnitsIds: [rootOrgUnit.id], - orgUnitMode: "DESCENDANTS", - }); - - const applicableEvents = allEvents.filter(event => - event.dataValues.some(dv => sourceIds.includes(dv.dataElement.id)) - ); + const allEvents = await this.fetchEvents(programStageId, rootOrgUnit.id); + const applicableEvents = this.filterApplicableEvents(allEvents, sourceIds); checkTargetDataValuesAreEmpty(applicableEvents, targetIds); @@ -44,6 +33,38 @@ export class CopyProgramStageDataValuesUseCase { dataElementPairs ); + await this.saveOrExport(eventsWithNewDataValues, post); + + if (reportPath) { + this.saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); + } + + return eventsWithNewDataValues; + } + + private async fetchElements(idPairs: [Id, Id][]) { + const rootOrgUnit = await this.orgUnitRepository.getRoot(); + const dataElements = await this.dataElementsRepository.getByIds(idPairs.flat()); + const dataElementPairs = this.mapDataElements(dataElements, idPairs); + const sourceIds = idPairs.map(([sourceId, _]) => sourceId); + const targetIds = idPairs.map(([_, targetId]) => targetId); + + return { rootOrgUnit, dataElementPairs, sourceIds, targetIds }; + } + + private async fetchEvents(programStageId: string, rootOrgUnitId: string) { + return this.programEventsRepository.get({ + programStagesIds: [programStageId], + orgUnitsIds: [rootOrgUnitId], + orgUnitMode: "DESCENDANTS", + }); + } + + private filterApplicableEvents(allEvents: ProgramEvent[], sourceIds: string[]) { + return allEvents.filter(event => event.dataValues.some(dv => sourceIds.includes(dv.dataElement.id))); + } + + private async saveOrExport(eventsWithNewDataValues: ProgramEvent[], post: boolean) { if (post) { const result = await this.programEventsRepository.save(eventsWithNewDataValues); if (result.type === "success") log.info(JSON.stringify(result, null, 4)); @@ -57,12 +78,6 @@ export class CopyProgramStageDataValuesUseCase { fs.writeFileSync(payloadPath, json); log.info(`Written payload (${eventsWithNewDataValues.length} events): ${payloadPath}`); } - - if (reportPath) { - this.saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); - } - - return eventsWithNewDataValues; } private copyEventDataValues( From b45c5ca5139797933e0efee2719941b224da1a00 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:19:51 +0000 Subject: [PATCH 11/16] Return types --- src/domain/usecases/CopyProgramStageDataValuesUseCase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index d5b2d1c7..5a1f7750 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -52,7 +52,7 @@ export class CopyProgramStageDataValuesUseCase { return { rootOrgUnit, dataElementPairs, sourceIds, targetIds }; } - private async fetchEvents(programStageId: string, rootOrgUnitId: string) { + private fetchEvents(programStageId: string, rootOrgUnitId: string): Promise { return this.programEventsRepository.get({ programStagesIds: [programStageId], orgUnitsIds: [rootOrgUnitId], @@ -60,7 +60,7 @@ export class CopyProgramStageDataValuesUseCase { }); } - private filterApplicableEvents(allEvents: ProgramEvent[], sourceIds: string[]) { + private filterApplicableEvents(allEvents: ProgramEvent[], sourceIds: string[]): ProgramEvent[] { return allEvents.filter(event => event.dataValues.some(dv => sourceIds.includes(dv.dataElement.id))); } @@ -84,7 +84,7 @@ export class CopyProgramStageDataValuesUseCase { applicableEvents: ProgramEvent[], sourceIds: string[], dataElementPairs: DataElementPair[] - ) { + ): ProgramEvent[] { return applicableEvents.map(event => ({ ...event, dataValues: event.dataValues.flatMap(dv => { From b323c126ea49c9d7b1548c76eb1bf7a57f95ff08 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:22:16 +0000 Subject: [PATCH 12/16] Declarative if/else statement --- .../CopyProgramStageDataValuesUseCase.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index 5a1f7750..dbe52353 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -88,19 +88,11 @@ export class CopyProgramStageDataValuesUseCase { return applicableEvents.map(event => ({ ...event, dataValues: event.dataValues.flatMap(dv => { + const targetDe = dataElementPairs.find(([source, _]) => source.id === dv.dataElement.id)?.[1]; + if (!sourceIds.includes(dv.dataElement.id)) return [dv]; - const target = dataElementPairs.find(([source, _]) => source.id === dv.dataElement.id)?.[1]; - - if (!target) - throw new Error(`Target data element not found for source id: ${dv.dataElement.id}`); - - return [ - dv, - { - ...dv, - dataElement: _.omit(target, "valueType"), - }, - ]; + else if (targetDe) return [dv, { ...dv, dataElement: _.omit(targetDe, "valueType") }]; + else throw new Error(`Target data element not found for source id: ${dv.dataElement.id}`); }), })); } From 79ca3bc280a684cdd2702e94b2d0bd2b1eefc3f3 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:34:26 +0000 Subject: [PATCH 13/16] Declarative tuple --- src/domain/usecases/CopyProgramStageDataValuesUseCase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index dbe52353..d9e366db 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -174,9 +174,9 @@ function checkTargetDataValuesAreEmpty(events: ProgramEvent[], targetIds: Id[]) export type CopyProgramStageDataValuesOptions = { programStageId: string; - dataElementIdPairs: [Id, Id][]; // [sourceDataElementId, targetDataElementId] + dataElementIdPairs: [source: Id, target: Id][]; post: boolean; saveReport?: string; }; -type DataElementPair = [DataElement, DataElement]; +type DataElementPair = [source: DataElement, target: DataElement]; From 523e3f51fac7bd79b21eef8ac16810e1a9dfc4b3 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:35:08 +0000 Subject: [PATCH 14/16] Add await on the cmd --- src/scripts/commands/programs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/commands/programs.ts b/src/scripts/commands/programs.ts index 22280f03..be60c79f 100644 --- a/src/scripts/commands/programs.ts +++ b/src/scripts/commands/programs.ts @@ -277,7 +277,7 @@ const copyDataValuesCmd = command({ const dataElementsRepository = new DataElementsD2Repository(api); const orgUnitRepository = new OrgUnitD2Repository(api); - new CopyProgramStageDataValuesUseCase( + await new CopyProgramStageDataValuesUseCase( programEventsRepository, orgUnitRepository, dataElementsRepository From 4ed5085d5a20c250036d18dc9fdb3b3e341fd346 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:52:32 +0000 Subject: [PATCH 15/16] Pairs to Mappings --- .../CopyProgramStageDataValuesUseCase.ts | 93 ++++++++++--------- .../CopyProgramStageDataValuesUseCase.spec.ts | 6 +- src/scripts/commands/programs.ts | 7 +- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts index d9e366db..96a9c8b8 100644 --- a/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts +++ b/src/domain/usecases/CopyProgramStageDataValuesUseCase.ts @@ -16,40 +16,38 @@ export class CopyProgramStageDataValuesUseCase { ) {} async execute(options: CopyProgramStageDataValuesOptions): Promise { - const { programStageId, dataElementIdPairs: idPairs, post, saveReport: reportPath } = options; + const { programStageId, dataElementIdMappings: idMappings, post, saveReport: reportPath } = options; - const { rootOrgUnit, dataElementPairs, sourceIds, targetIds } = await this.fetchElements(idPairs); + const { rootOrgUnit, deMappings, sourceIds, targetIds } = await this.fetchElements(idMappings); - checkDataElementTypes(dataElementPairs); + checkDataElementTypes(deMappings); const allEvents = await this.fetchEvents(programStageId, rootOrgUnit.id); const applicableEvents = this.filterApplicableEvents(allEvents, sourceIds); checkTargetDataValuesAreEmpty(applicableEvents, targetIds); - const eventsWithNewDataValues = this.copyEventDataValues( - applicableEvents, - sourceIds, - dataElementPairs - ); + const eventsWithNewDataValues = this.copyEventDataValues({ applicableEvents, sourceIds, deMappings }); await this.saveOrExport(eventsWithNewDataValues, post); if (reportPath) { - this.saveReport(reportPath, dataElementPairs, programStageId, eventsWithNewDataValues); + this.saveReport({ path: reportPath, deMappings, programStageId, eventsWithNewDataValues }); } return eventsWithNewDataValues; } - private async fetchElements(idPairs: [Id, Id][]) { + private async fetchElements(idMappings: { source: Id; target: Id }[]) { const rootOrgUnit = await this.orgUnitRepository.getRoot(); - const dataElements = await this.dataElementsRepository.getByIds(idPairs.flat()); - const dataElementPairs = this.mapDataElements(dataElements, idPairs); - const sourceIds = idPairs.map(([sourceId, _]) => sourceId); - const targetIds = idPairs.map(([_, targetId]) => targetId); + const dataElements = await this.dataElementsRepository.getByIds( + idMappings.flatMap(({ source, target }) => [source, target]) + ); + const deMappings = this.mapDataElements(dataElements, idMappings); + const sourceIds = idMappings.map(({ source }) => source); + const targetIds = idMappings.map(({ target }) => target); - return { rootOrgUnit, dataElementPairs, sourceIds, targetIds }; + return { rootOrgUnit, deMappings, sourceIds, targetIds }; } private fetchEvents(programStageId: string, rootOrgUnitId: string): Promise { @@ -80,15 +78,17 @@ export class CopyProgramStageDataValuesUseCase { } } - private copyEventDataValues( - applicableEvents: ProgramEvent[], - sourceIds: string[], - dataElementPairs: DataElementPair[] - ): ProgramEvent[] { + private copyEventDataValues(args: { + applicableEvents: ProgramEvent[]; + sourceIds: string[]; + deMappings: DataElementMapping[]; + }): ProgramEvent[] { + const { applicableEvents, sourceIds, deMappings } = args; + return applicableEvents.map(event => ({ ...event, dataValues: event.dataValues.flatMap(dv => { - const targetDe = dataElementPairs.find(([source, _]) => source.id === dv.dataElement.id)?.[1]; + const targetDe = deMappings.find(({ source }) => source.id === dv.dataElement.id)?.target; if (!sourceIds.includes(dv.dataElement.id)) return [dv]; else if (targetDe) return [dv, { ...dv, dataElement: _.omit(targetDe, "valueType") }]; @@ -97,35 +97,40 @@ export class CopyProgramStageDataValuesUseCase { })); } - private mapDataElements(dataElements: DataElement[], pairs: [Id, Id][]): DataElementPair[] { - const dataElementPairs = pairs.map(([sourceId, targetId]) => { - const sourceElement = dataElements.find(de => de.id === sourceId); - const targetElement = dataElements.find(de => de.id === targetId); + private mapDataElements( + dataElements: DataElement[], + idMappings: { source: Id; target: Id }[] + ): DataElementMapping[] { + const deMappings = idMappings.map(({ source, target }) => { + const sourceElement = dataElements.find(de => de.id === source); + const targetElement = dataElements.find(de => de.id === target); if (!sourceElement || !targetElement) - return `Data element not found for pair: [${sourceId}, ${targetId}]`; - else return [sourceElement, targetElement]; + return `Data element not found for pair: ${source}-${target}`; + else return { source: sourceElement, target: targetElement }; }); - const errors = dataElementPairs.filter(pair => typeof pair === "string"); + const errors = deMappings.filter(mapping => typeof mapping === "string"); if (!_.isEmpty(errors)) throw new Error(errors.join("\n")); - return dataElementPairs.filter((pair): pair is DataElementPair => typeof pair !== "string"); + return deMappings.filter((mapping): mapping is DataElementMapping => typeof mapping !== "string"); } - private saveReport( - path: string, - dataElementPairs: DataElementPair[], - programStageId: string, - eventsWithNewDataValues: ProgramEvent[] - ) { - const dataElementLines = dataElementPairs.map( - ([source, target]) => + private saveReport(args: { + path: string; + deMappings: DataElementMapping[]; + programStageId: string; + eventsWithNewDataValues: ProgramEvent[]; + }) { + const { path, deMappings, programStageId, eventsWithNewDataValues } = args; + + const dataElementLines = deMappings.map( + ({ source, target }) => `Source DataElement: ${source.id} (${source.name}), Target DataElement: ${target.id} (${target.name})` ); const eventLines = eventsWithNewDataValues.map(event => { - const dataValueLines = dataElementPairs.flatMap(([source, target]) => { + const dataValueLines = deMappings.flatMap(({ source, target }) => { const sourceValue = event.dataValues.find(dv => dv.dataElement.id === source.id)?.value; const status = sourceValue ? `(${sourceValue})` : undefined; return status ? [`\tCopy ${source.id} to ${target.id} ${status}`] : []; @@ -146,10 +151,10 @@ export class CopyProgramStageDataValuesUseCase { } } -function checkDataElementTypes(dePairs: DataElementPair[]) { - const typeMismatchErrors = dePairs - .filter(([source, target]) => source.valueType !== target.valueType) - .map(([source, target]) => `Data elements [${source.id}, ${target.id}] do not have the same type.`); +function checkDataElementTypes(deMappings: DataElementMapping[]) { + const typeMismatchErrors = deMappings + .filter(({ source, target }) => source.valueType !== target.valueType) + .map(({ source, target }) => `Data elements [${source.id}, ${target.id}] do not have the same type.`); if (!_.isEmpty(typeMismatchErrors)) throw new Error(typeMismatchErrors.join("\n")); } @@ -174,9 +179,9 @@ function checkTargetDataValuesAreEmpty(events: ProgramEvent[], targetIds: Id[]) export type CopyProgramStageDataValuesOptions = { programStageId: string; - dataElementIdPairs: [source: Id, target: Id][]; + dataElementIdMappings: { source: Id; target: Id }[]; post: boolean; saveReport?: string; }; -type DataElementPair = [source: DataElement, target: DataElement]; +type DataElementMapping = { source: DataElement; target: DataElement }; diff --git a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts index 3d8c629f..06315521 100644 --- a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts +++ b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts @@ -86,9 +86,9 @@ describe("CopyProgramStageDataValuesUseCase", () => { const commonArgs: CopyProgramStageDataValuesOptions = { programStageId: "sgSKZRoWE9b", - dataElementIdPairs: [ - ["qwfxR2TQkUn", "L5x9z9BAgR8"], - ["N0p2yOsEy7a", "wSCh46cADN6"], + dataElementIdMappings: [ + { source: "qwfxR2TQkUn", target: "L5x9z9BAgR8" }, + { source: "N0p2yOsEy7a", target: "wSCh46cADN6" }, ], post: false, }; diff --git a/src/scripts/commands/programs.ts b/src/scripts/commands/programs.ts index be60c79f..2976843e 100644 --- a/src/scripts/commands/programs.ts +++ b/src/scripts/commands/programs.ts @@ -277,11 +277,16 @@ const copyDataValuesCmd = command({ const dataElementsRepository = new DataElementsD2Repository(api); const orgUnitRepository = new OrgUnitD2Repository(api); + const useCaseArgs = { + ...args, + dataElementIdMappings: args.dataElementIdPairs.map(([source, target]) => ({ source, target })), + }; + await new CopyProgramStageDataValuesUseCase( programEventsRepository, orgUnitRepository, dataElementsRepository - ).execute(args); + ).execute(useCaseArgs); }, }); From 89c241a785a7f72cc522a56790c81c5afeaf7973 Mon Sep 17 00:00:00 2001 From: p3rcypj Date: Tue, 7 Jan 2025 12:53:36 +0000 Subject: [PATCH 16/16] Fix test --- .../__tests__/CopyProgramStageDataValuesUseCase.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts index 06315521..ab89d418 100644 --- a/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts +++ b/src/domain/usecases/__tests__/CopyProgramStageDataValuesUseCase.spec.ts @@ -65,7 +65,7 @@ describe("CopyProgramStageDataValuesUseCase", () => { dataElementsRepository.getByIds = vi.fn().mockResolvedValue(missingDataElements); await expect(useCase.execute(commonArgs)).rejects.toThrow( - "Data element not found for pair: [qwfxR2TQkUn, L5x9z9BAgR8]" + "Data element not found for pair: qwfxR2TQkUn-L5x9z9BAgR8" ); });