diff --git a/src/data/Dhis2RelationshipTypes.ts b/src/data/Dhis2RelationshipTypes.ts index 02f4ef16..979ea35c 100644 --- a/src/data/Dhis2RelationshipTypes.ts +++ b/src/data/Dhis2RelationshipTypes.ts @@ -11,21 +11,30 @@ import { promiseMap } from "../utils/promises"; import { getUid } from "./dhis2-uid"; import { getTrackedEntities, TrackedEntityGetRequest } from "./Dhis2TrackedEntityInstances"; import { TrackerRelationship, RelationshipItem, TrackedEntitiesApiRequest } from "../domain/entities/TrackedEntity"; -import { buildOrgUnitsParameter } from "../domain/entities/OrgUnit"; import { EventsAPIResponse } from "../domain/entities/DhisDataPackage"; +import { getVersion, getMajorVersion } from "../utils/d2-api"; type RelationshipTypesById = Record>; export type RelationshipOrgUnitFilter = TrackedEntityOURequestApi["ouMode"]; -export function buildOrgUnitMode(ouMode: RelationshipOrgUnitFilter, orgUnits?: Ref[]) { +function buildOrgUnitsParameter(apiVersion: number, orgUnitsIds: Ref[]): string { + const separator = apiVersion >= 42 ? "," : ";"; + return orgUnitsIds.map(({ id }) => id).join(separator); +} + +export function buildOrgUnitMode(apiVersion: string, ouMode: RelationshipOrgUnitFilter, orgUnits?: Ref[]) { + const majorVersion = getMajorVersion(apiVersion); const isOuReq = ouMode === "SELECTED" || ouMode === "CHILDREN" || ouMode === "DESCENDANTS"; - //issue: v41 - orgUnitMode/ouMode; v38-40 ouMode; ouMode to be deprecated + //issue: v42 - orgUnitMode and orgUnits; v41 - orgUnitMode/ouMode and orgUnit; v38-40 ouMode; ouMode to be deprecated //can't use both orgUnitMode and ouMode in v41 if (!isOuReq) { - return { ouMode }; + if (majorVersion >= 42) return { orgUnitMode: ouMode }; + else return { ouMode }; } else if (orgUnits && orgUnits.length > 0) { - return { ouMode, orgUnit: buildOrgUnitsParameter(orgUnits) }; + const orgUnitsParam = buildOrgUnitsParameter(majorVersion, orgUnits); + if (majorVersion >= 42) return { orgUnitMode: ouMode, orgUnits: orgUnitsParam }; + else return { ouMode, orgUnit: orgUnitsParam }; } else { throw new Error(`No orgUnits selected for ouMode ${ouMode}`); } @@ -169,6 +178,8 @@ export async function getRelationshipMetadata( api: D2Api, filters?: ProgramFilters ): Promise { + const apiVersion = await getVersion(api); + const { trackedEntityTypes, relationshipTypes: allRelationshipTypes, @@ -201,8 +212,22 @@ export async function getRelationshipMetadata( if (!isProgramAssociatedWithSomeConstraint) return; - const fromConstraint = await getConstraint(api, trackedEntityTypes, programs, type.fromConstraint, filters); - const toConstraint = await getConstraint(api, trackedEntityTypes, programs, type.toConstraint, filters); + const fromConstraint = await getConstraint( + api, + apiVersion, + trackedEntityTypes, + programs, + type.fromConstraint, + filters + ); + const toConstraint = await getConstraint( + api, + apiVersion, + trackedEntityTypes, + programs, + type.toConstraint, + filters + ); return fromConstraint && toConstraint ? { id: relType.id, name: relType.name, constraints: { from: fromConstraint, to: toConstraint } } @@ -217,6 +242,7 @@ type ProgramInfo = NamedRef & { programStages: NamedRef[] }; const getConstraint = memoizeAsync( async ( api: D2Api, + apiVersion: string, trackedEntityTypes: NamedRef[], programs: ProgramInfo[], constraint: D2RelationshipConstraint, @@ -235,7 +261,7 @@ const getConstraint = memoizeAsync( switch (constraint.relationshipEntity) { case "TRACKED_ENTITY_INSTANCE": - return getConstraintForTypeTei(api, filters, trackedEntityTypes, constraint); + return getConstraintForTypeTei(api, apiVersion, filters, trackedEntityTypes, constraint); case "PROGRAM_STAGE_INSTANCE": { if (constraint.program) { const program = programsById[constraint.program.id]; @@ -251,6 +277,7 @@ const getConstraint = memoizeAsync( async function getConstraintForTypeTei( api: D2Api, + apiVersion: string, filters: ProgramFilters | undefined, trackedEntityTypes: NamedRef[], constraint: D2RelationshipConstraint @@ -258,7 +285,7 @@ async function getConstraintForTypeTei( const { ouMode = "CAPTURE", organisationUnits = [] } = filters || {}; const trackedEntityTypesById = _.keyBy(trackedEntityTypes, obj => obj.id); - const ouModeQuery = buildOrgUnitMode(ouMode, organisationUnits); + const ouModeQuery = buildOrgUnitMode(apiVersion, ouMode, organisationUnits); const query = { ...ouModeQuery, diff --git a/src/data/Dhis2TrackedEntityInstances.ts b/src/data/Dhis2TrackedEntityInstances.ts index 441bbaa2..34c50793 100644 --- a/src/data/Dhis2TrackedEntityInstances.ts +++ b/src/data/Dhis2TrackedEntityInstances.ts @@ -28,8 +28,9 @@ import { TrackedEntitiesApiRequest, TrackedEntitiesResponse, TrackedEntity } fro import { Params } from "@eyeseetea/d2-api/api/common"; import { ImportDataPackageOptions } from "../domain/repositories/InstanceRepository"; import { MULTI_TEXT_OPTION_DELIMITER } from "../domain/helpers/ExcelBuilder"; +import { getVersion } from "../utils/d2-api"; -export interface GetOptions { +type GetOptions = { api: D2Api; program: Ref; orgUnits: Ref[]; @@ -37,7 +38,7 @@ export interface GetOptions { enrollmentStartDate?: Moment; enrollmentEndDate?: Moment; relationshipsOuFilter?: RelationshipOrgUnitFilter; -} +}; type TrackerParams = Params & Omit; @@ -60,6 +61,8 @@ export async function getTrackedEntityInstances(options: GetOptions): Promise { + const apiVersion = await getVersion(api); + const ouModeParam = buildOrgUnitMode(apiVersion, "CAPTURE"); + const query = { - ouMode: "CAPTURE", + ...ouModeParam, pageSize: 1000, totalPages: true, fields: "trackedEntity", @@ -504,6 +511,7 @@ type TeiKey = KeysOfUnion; async function getTeisFromApi(options: { api: D2Api; + apiVersion: string; program: Program; orgUnits: Ref[]; page: number; @@ -512,7 +520,8 @@ async function getTeisFromApi(options: { enrollmentEndDate?: Moment; ouMode: RelationshipOrgUnitFilter; }): Promise { - const { api, program, orgUnits, page, pageSize, enrollmentStartDate, enrollmentEndDate, ouMode } = options; + const { api, apiVersion, program, orgUnits, page, pageSize, enrollmentStartDate, enrollmentEndDate, ouMode } = + options; const fields: TeiKey[] = [ "trackedEntity", @@ -524,7 +533,7 @@ async function getTeisFromApi(options: { "geometry", ]; - const ouModeQuery = buildOrgUnitMode(ouMode, orgUnits); + const ouModeQuery = buildOrgUnitMode(apiVersion, ouMode, orgUnits); const filters: TrackedEntityGetRequest = { ...ouModeQuery, diff --git a/src/data/__tests__/Dhis2RelationshipTypes.spec.ts b/src/data/__tests__/Dhis2RelationshipTypes.spec.ts new file mode 100644 index 00000000..b32f7cc9 --- /dev/null +++ b/src/data/__tests__/Dhis2RelationshipTypes.spec.ts @@ -0,0 +1,42 @@ +import { buildOrgUnitMode } from "../Dhis2RelationshipTypes"; + +const orgUnits = [{ id: "ou1" }, { id: "ou2" }]; + +describe("buildOrgUnitMode", () => { + describe("on versions before v42", () => { + it("uses ouMode for non org-unit-requiring modes", () => { + expect(buildOrgUnitMode("2.41.6", "CAPTURE")).toEqual({ ouMode: "CAPTURE" }); + }); + + it("uses ouMode + orgUnit (singular, semicolon-separated) for selected org units", () => { + expect(buildOrgUnitMode("2.41.6", "SELECTED", orgUnits)).toEqual({ + ouMode: "SELECTED", + orgUnit: "ou1;ou2", + }); + }); + }); + + describe("on v42 and later", () => { + it("uses orgUnitMode for non org-unit-requiring modes", () => { + expect(buildOrgUnitMode("2.42.0", "CAPTURE")).toEqual({ orgUnitMode: "CAPTURE" }); + }); + + it("uses orgUnitMode + orgUnits (plural, comma-separated) for selected org units", () => { + expect(buildOrgUnitMode("2.42.0", "SELECTED", orgUnits)).toEqual({ + orgUnitMode: "SELECTED", + orgUnits: "ou1,ou2", + }); + }); + + it("treats pre-release builds as their major version (regression for NaN parsing)", () => { + expect(buildOrgUnitMode("2.44-SNAPSHOT", "SELECTED", orgUnits)).toEqual({ + orgUnitMode: "SELECTED", + orgUnits: "ou1,ou2", + }); + }); + }); + + it("throws when an org-unit-requiring mode has no org units", () => { + expect(() => buildOrgUnitMode("2.42.0", "SELECTED", [])).toThrow(); + }); +}); diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts index 3e7d8f35..17ac2128 100644 --- a/src/domain/entities/OrgUnit.ts +++ b/src/domain/entities/OrgUnit.ts @@ -1,4 +1,4 @@ -import { Id, Ref } from "./ReferenceObject"; +import { Id } from "./ReferenceObject"; export interface OrgUnit { id: Id; @@ -6,7 +6,3 @@ export interface OrgUnit { name: string; level: number; } - -export function buildOrgUnitsParameter(orgUnitsIds: Ref[]): string { - return orgUnitsIds.map(({ id }) => id).join(";"); -} diff --git a/src/utils/__tests__/d2-api.spec.ts b/src/utils/__tests__/d2-api.spec.ts new file mode 100644 index 00000000..9e5aaf9d --- /dev/null +++ b/src/utils/__tests__/d2-api.spec.ts @@ -0,0 +1,27 @@ +import { getMajorVersion } from "../d2-api"; + +describe("getMajorVersion", () => { + it("parses a stable release version", () => { + expect(getMajorVersion("2.42.0")).toEqual(42); + }); + + it("parses a fork build (suffix on a later component)", () => { + expect(getMajorVersion("2.41.6.1-eyeseetea-fork-1")).toEqual(41); + }); + + it("parses a pre-release build (suffix on the minor component)", () => { + expect(getMajorVersion("2.44-SNAPSHOT")).toEqual(44); + }); + + it("parses a version with only major.minor", () => { + expect(getMajorVersion("2.40")).toEqual(40); + }); + + it("throws on a version without a minor component", () => { + expect(() => getMajorVersion("2")).toThrow(); + }); + + it("throws on a non-numeric minor component", () => { + expect(() => getMajorVersion("2.x")).toThrow(); + }); +}); diff --git a/src/utils/d2-api.ts b/src/utils/d2-api.ts index d9eec508..93298610 100644 --- a/src/utils/d2-api.ts +++ b/src/utils/d2-api.ts @@ -1,11 +1,16 @@ import _ from "lodash"; import { DhisInstance } from "../domain/entities/DhisInstance"; import { D2Api } from "../types/d2-api"; +import { memoizeAsync } from "./cache"; export function getMajorVersion(version: string): number { const apiVersion = _.get(version.split("."), 1); if (!apiVersion) throw new Error(`Invalid version: ${version}`); - return Number(apiVersion); + // Use parseInt so pre-release suffixes on the minor component are tolerated + // (e.g. "2.44-SNAPSHOT" -> 44 instead of NaN). + const majorVersion = parseInt(apiVersion, 10); + if (Number.isNaN(majorVersion)) throw new Error(`Invalid version: ${version}`); + return majorVersion; } export function getD2APiFromInstance(instance: DhisInstance) { @@ -15,3 +20,8 @@ export function getD2APiFromInstance(instance: DhisInstance) { backend: "fetch", }); } + +export const getVersion = memoizeAsync(async (api: D2Api): Promise => { + const { version } = await api.system.info.getData(); + return version; +});