Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/fix-tracker-ou-params-d2-api/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-19
52 changes: 52 additions & 0 deletions openspec/changes/fix-tracker-ou-params-d2-api/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Context

The Bulk Load app fetches tracked entity instances (TEIs) from DHIS2 via the `/tracker/trackedEntities` endpoint during template population and relationship metadata retrieval. Currently, it bypasses d2-api's typed tracker methods and uses raw `api.get()` calls with a custom `TrackedEntityGetRequest` interface that manually assembles query parameters. DHIS2 v42 renamed `ouMode` → `orgUnitMode` and `orgUnit` (semicolon-delimited) → `orgUnits` (comma-delimited), and also changed the response shape from `{ instances, pageCount }` to `{ pager: { pageCount }, trackedEntities }`.

The d2-api library is being updated (PR #184) to handle these v42 changes in its typed `api.tracker.trackedEntities.get()` method. Rather than duplicating the parameter fix in Bulk Load, we should leverage d2-api's typed API.

## Goals / Non-Goals

**Goals:**
- Use d2-api's typed `api.tracker.trackedEntities.get()` for all tracked entity queries
- Remove manual parameter construction and response normalization code
- Ensure type safety via d2-api's parameter types (compile-time validation of param names)
- Maintain identical runtime behavior (same HTTP requests, same data flow)

**Non-Goals:**
- Refactoring the events API calls (`/tracker/events`) — those remain as raw `api.get()` for now
- Adding backward compatibility with DHIS2 < v41 — the tracker API (`/tracker/trackedEntities`) is v38+ and the codebase already targets v41+
- Changing the domain layer entities or use cases

## Decisions

### 1. Use d2-api's typed tracker API instead of raw `api.get()`

**Decision**: Replace `api.get<TrackedEntitiesD2ApiResponse>("/tracker/trackedEntities", filterQuery)` with `api.tracker.trackedEntities.get({ ... })`.

**Rationale**: d2-api's typed method handles parameter serialization (field selectors, order params) and response mapping internally. This means Bulk Load doesn't need to maintain its own parameter types or response compat layer. When d2-api updates for future DHIS2 versions, the fix propagates automatically.

**Alternative considered**: Keep raw `api.get()` but update param names (PR #386 approach). Simpler short-term but leaves the app coupled to specific DHIS2 API parameter naming.

### 2. Derive parameter types from d2-api instead of maintaining custom interfaces

**Decision**: Use `Parameters<D2Api["tracker"]["trackedEntities"]["get"]>[0]` as the type for tracker query params. Remove `TrackedEntityGetRequest`, `TrackerParams`, and related types.

**Rationale**: Custom types drift from d2-api's actual API. Deriving types from the library ensures they stay in sync.

### 3. Use typed field selectors instead of comma-separated strings

**Decision**: Replace `fields: "trackedEntity,inactive,orgUnit,attributes,enrollments,relationships,geometry"` with `fields: { trackedEntity: true, inactive: true, ... }`.

**Rationale**: d2-api validates field selectors at compile time. String-based fields provide no type safety and can silently include invalid field names.

### 4. Cast d2-api response to domain type via `as unknown as TrackedEntitiesApiRequest[]`

**Decision**: In `getTeisFromApi`, the d2-api response is cast to the domain's `TrackedEntitiesApiRequest` type.

**Rationale**: The d2-api response type uses `SelectedPick` which produces structurally compatible but nominally different types. A cast is the pragmatic choice since the shapes are identical at runtime. Longer term, the domain type could be replaced with d2-api's type directly.

## Risks / Trade-offs

- **[Dependency on unreleased d2-api]** → The d2-api PR #184 must be merged and published before this can be released. `package.json` temporarily points to the git branch. Mitigation: track d2-api PR status; update to published version once available.
- **[Type cast in getTeisFromApi]** → The `as unknown as TrackedEntitiesApiRequest[]` cast bypasses type checking at one boundary. Mitigation: the types are structurally identical; this can be removed if domain types are aligned with d2-api types in a future refactor.
- **[Events API not updated]** → The `/tracker/events` calls in `Dhis2RelationshipTypes.ts` still use raw `api.get()`. These may need similar treatment when d2-api's event types are updated. Mitigation: tracked as a separate follow-up.
31 changes: 31 additions & 0 deletions openspec/changes/fix-tracker-ou-params-d2-api/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Why

DHIS2 v42 deprecated the `ouMode` and `orgUnit` (semicolon-delimited) query parameters on the `/tracker/trackedEntities` endpoint, replacing them with `orgUnitMode` and `orgUnits` (comma-delimited). The Bulk Load app currently bypasses d2-api's typed tracker methods and constructs raw `api.get()` calls with manually assembled parameters. This causes TEI population to silently return all accessible TEIs instead of only those in selected org units on v42 instances. The fix in d2-api PR #184 already addresses the parameter naming at the library level — this change refactors Bulk Load to use that typed API instead of duplicating the fix locally.

## What Changes

- Upgrade `@eyeseetea/d2-api` to the version that includes v42 tracker parameter support (PR #184)
- Update d2-api import path from `2.41` to `2.42`
- Replace all raw `api.get("/tracker/trackedEntities", ...)` calls with d2-api's typed `api.tracker.trackedEntities.get()` method
- Remove custom `TrackedEntityGetRequest` interface and `TrackerParams` type — d2-api's types handle parameter validation
- Remove `getTrackedEntities()` raw API wrapper function and `TrackedEntitiesD2ApiResponse` compat type
- Update response handling from `instances` / `pageCount` to `pager.pageCount` / `trackedEntities` (v42 response shape)
- Replace string-based field selectors (`"trackedEntity,inactive,orgUnit,..."`) with typed object selectors (`{ trackedEntity: true, ... }`)
- Rename `buildOrgUnitMode` → `buildOrgUnitParams` to reflect the v42 parameter naming

## Capabilities

### New Capabilities

_(none — this is a refactor of existing functionality)_

### Modified Capabilities

- `tracker-ou-filtering`: Implementation changes from raw API calls to d2-api typed methods. The behavioral requirements remain the same (orgUnitMode, comma-delimited orgUnits), but the mechanism shifts from manual parameter construction to leveraging d2-api's typed tracker API.

## Impact

- **Dependencies**: `@eyeseetea/d2-api` upgraded from `1.20.0` to the version including PR #184 (v42 tracker support). This is a **prerequisite** — the d2-api PR must be merged and released first.
- **Code**: `src/data/Dhis2TrackedEntityInstances.ts`, `src/data/Dhis2RelationshipTypes.ts`, `src/domain/entities/TrackedEntity.ts`, `src/types/d2-api.ts`
- **Removed exports**: `getTrackedEntities`, `TrackedEntityGetRequest`, `TrackedEntitiesD2ApiResponse` from `Dhis2TrackedEntityInstances.ts`; `buildOrgUnitMode` renamed to `buildOrgUnitParams` in `Dhis2RelationshipTypes.ts`; `TrackedEntitiesResponse` and `TrackedEntitiesAPIResponse` removed from `TrackedEntity.ts`
- **No behavioral changes**: The API calls produce identical HTTP requests; only the mechanism for constructing them changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## MODIFIED Requirements

### Requirement: Tracker API queries use d2-api typed methods

All calls to the DHIS2 `/tracker/trackedEntities` endpoint SHALL use d2-api's typed `api.tracker.trackedEntities.get()` method instead of raw `api.get()` calls with manually constructed parameters. The d2-api library handles the v42 parameter naming (`orgUnitMode`, `orgUnits` comma-delimited) internally.

#### Scenario: TEI population uses typed tracker API
- **WHEN** `getTeisFromApi` fetches tracked entities for template population
- **THEN** it SHALL call `api.tracker.trackedEntities.get()` with typed field selectors and `orgUnitMode`/`orgUnits` parameters via `buildOrgUnitParams()`
- **THEN** it SHALL NOT use raw `api.get("/tracker/trackedEntities", ...)` with string-based parameters

#### Scenario: Existing TEI lookup uses typed tracker API
- **WHEN** `getExistingTeis` fetches all existing TEIs for relationship splitting
- **THEN** it SHALL call `api.tracker.trackedEntities.get()` with `orgUnitMode: "CAPTURE"` and typed field selectors
- **THEN** it SHALL NOT use the deprecated `ouMode` parameter name

#### Scenario: Relationship constraint TEI lookup uses typed tracker API
- **WHEN** `getAllTrackedEntities` fetches TEIs for relationship constraint resolution
- **THEN** it SHALL call `api.tracker.trackedEntities.get()` with typed parameters
- **THEN** it SHALL handle the v42 response shape (`{ pager, trackedEntities }`) directly from d2-api
23 changes: 23 additions & 0 deletions openspec/changes/fix-tracker-ou-params-d2-api/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## 1. Upgrade d2-api dependency

- [x] 1.1 [BE] Upgrade `@eyeseetea/d2-api` to the version including PR #184 (v42 tracker params)
- [x] 1.2 [BE] Update `src/types/d2-api.ts` imports from `@eyeseetea/d2-api/2.41` to `@eyeseetea/d2-api/2.42`

## 2. Refactor tracker API calls to use d2-api typed methods

- [x] 2.1 [BE] Replace `buildOrgUnitMode` with `buildOrgUnitParams` in `Dhis2RelationshipTypes.ts` — use `OrgUnitMode` from d2-api instead of the removed `TeiOuRequest`
- [x] 2.2 [BE] Refactor `getTeisFromApi` in `Dhis2TrackedEntityInstances.ts` to use `api.tracker.trackedEntities.get()` with typed field selectors instead of raw `api.get()`
- [x] 2.3 [BE] Refactor `getExistingTeis` in `Dhis2TrackedEntityInstances.ts` to use `api.tracker.trackedEntities.get()` with `orgUnitMode: "CAPTURE"`
- [x] 2.4 [BE] Refactor `getAllTrackedEntities` in `Dhis2RelationshipTypes.ts` to use `api.tracker.trackedEntities.get()` and handle the v42 response shape

## 3. Remove obsolete types and compat code

- [x] 3.1 [BE] Remove `TrackedEntityGetRequest` interface, `TrackerParams` type, and `getTrackedEntities` raw API wrapper from `Dhis2TrackedEntityInstances.ts`
- [x] 3.2 [BE] Remove `TrackedEntitiesD2ApiResponse` compat type from `Dhis2TrackedEntityInstances.ts`
- [x] 3.3 [BE] Remove `TrackedEntitiesResponse` and `TrackedEntitiesAPIResponse` backward-compat types from `TrackedEntity.ts`
- [x] 3.4 [BE] Define `TrackedEntityGeometryAttributes` locally (replaces import from removed `trackedEntityInstances` module)

## 4. Verification

- [x] 4.1 [BE] Verify TypeScript compilation passes (`npx tsc --noEmit`) with no new errors in modified files
- [x] 4.2 [BE] Verify all unit tests pass (`yarn test`)
16 changes: 16 additions & 0 deletions openspec/specs/tracker-ou-filtering/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
### Requirement: Tracker API queries use orgUnitMode parameter

All calls to the DHIS2 `/tracker/trackedEntities` endpoint SHALL use the `orgUnitMode` query parameter instead of the deprecated `ouMode` parameter. This applies to TEI fetching for template population and relationship metadata retrieval.

#### Scenario: TEI population with "Only selected organisation units"
- **WHEN** user downloads a tracker template with populate enabled and "TEI and relationships enrollment by organisation unit" set to "Only selected organisation units"
- **THEN** the API call to `/tracker/trackedEntities` SHALL include `orgUnitMode=SELECTED` and `orgUnits=<comma-separated selected OU ids>` as query parameters
- **THEN** the returned TEIs SHALL only include those enrolled in the selected organisation units

#### Scenario: TEI population with "Current user organisation units"
- **WHEN** user downloads a tracker template with populate enabled and OU filter set to "Current user organisation units (data capture)"
- **THEN** the API call to `/tracker/trackedEntities` SHALL include `orgUnitMode=CAPTURE` as query parameter

#### Scenario: Relationship metadata fetching respects OU filter
- **WHEN** relationship metadata is fetched for TEI constraints during template population
- **THEN** the API call to `/tracker/trackedEntities` SHALL use `orgUnitMode` (not `ouMode`) with the user's selected OU filter mode
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@dhis2/d2-ui-core": "7.3.3",
"@dhis2/ui-core": "6.24.0",
"@dhis2/ui-widgets": "6.24.0",
"@eyeseetea/d2-api": "1.20.0",
"@eyeseetea/d2-api": "EyeSeeTea/d2-api#feature/tracker-orgunitparams-42-869beeny2",
"@eyeseetea/d2-ui-components": "2.12.0",
"@eyeseetea/feedback-component": "0.1.3-beta.3",
"@eyeseetea/xlsx-populate": "4.3.2-beta.1",
Expand Down
51 changes: 29 additions & 22 deletions src/data/Dhis2RelationshipTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TeiOuRequest as TrackedEntityOURequestApi } from "@eyeseetea/d2-api/api/trackedEntityInstances";
import { OrgUnitMode } from "@eyeseetea/d2-api/api/trackerTrackedEntities";
import _ from "lodash";
import moment from "moment";
import { NamedRef } from "../domain/entities/ReferenceObject";
Expand All @@ -9,25 +9,27 @@ import { D2Api, D2RelationshipConstraint, D2RelationshipType, Id, Ref } from "..
import { memoizeAsync } from "../utils/cache";
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";

type RelationshipTypesById = Record<Id, Pick<D2RelationshipType, "id" | "toConstraint" | "fromConstraint">>;

export type RelationshipOrgUnitFilter = TrackedEntityOURequestApi["ouMode"];
export type RelationshipOrgUnitFilter = OrgUnitMode;

export function buildOrgUnitMode(ouMode: RelationshipOrgUnitFilter, orgUnits?: Ref[]) {
const isOuReq = ouMode === "SELECTED" || ouMode === "CHILDREN" || ouMode === "DESCENDANTS";
//issue: v41 - orgUnitMode/ouMode; v38-40 ouMode; ouMode to be deprecated
//can't use both orgUnitMode and ouMode in v41
if (!isOuReq) {
return { ouMode };
export function buildOrgUnitParams(
orgUnitMode: RelationshipOrgUnitFilter,
orgUnits?: Ref[]
): { orgUnitMode: OrgUnitMode; orgUnits?: string } {
const requiresOrgUnits =
orgUnitMode === "SELECTED" || orgUnitMode === "CHILDREN" || orgUnitMode === "DESCENDANTS";

if (!requiresOrgUnits) {
return { orgUnitMode };
} else if (orgUnits && orgUnits.length > 0) {
return { ouMode, orgUnit: buildOrgUnitsParameter(orgUnits) };
return { orgUnitMode, orgUnits: buildOrgUnitsParameter(orgUnits) };
} else {
throw new Error(`No orgUnits selected for ouMode ${ouMode}`);
throw new Error(`No orgUnits selected for orgUnitMode ${orgUnitMode}`);
}
}

Expand Down Expand Up @@ -258,22 +260,21 @@ async function getConstraintForTypeTei(
const { ouMode = "CAPTURE", organisationUnits = [] } = filters || {};
const trackedEntityTypesById = _.keyBy(trackedEntityTypes, obj => obj.id);

const ouModeQuery = buildOrgUnitMode(ouMode, organisationUnits);
const orgUnitParams = buildOrgUnitParams(ouMode, organisationUnits);

const query = {
...ouModeQuery,
order: "createdAt:asc",
...orgUnitParams,
order: [{ type: "field" as const, field: "createdAt" as const, direction: "asc" as const }],
program: constraint.program?.id,
// Program and tracked entity cannot be specified simultaneously
trackedEntityType: constraint.program ? undefined : constraint.trackedEntityType.id,
pageSize: 1000,
totalPages: true,
fields: "trackedEntity",
} as const;
fields: { trackedEntity: true as const },
};

const results = await getAllTrackedEntities(api, query);
const trackedEntityInstances = results.map(({ trackedEntity, ...rest }) => ({
...rest,
const trackedEntityInstances = results.map(({ trackedEntity }) => ({
id: trackedEntity,
}));

Expand All @@ -283,12 +284,18 @@ async function getConstraintForTypeTei(
return { type: "tei", name, program: constraint.program, teis };
}

async function getAllTrackedEntities(api: D2Api, query: TrackedEntityGetRequest): Promise<TrackedEntitiesApiRequest[]> {
const { instances: firstPage, pageCount } = await getTrackedEntities(api, query);
type TrackerGetParams = Parameters<D2Api["tracker"]["trackedEntities"]["get"]>[0];

async function getAllTrackedEntities(
api: D2Api,
query: TrackerGetParams
): Promise<{ trackedEntity: string }[]> {
const { pager, trackedEntities: firstPage } = await api.tracker.trackedEntities.get(query).getData();
const pageCount = pager.pageCount ?? 0;
const pages = _.range(2, pageCount + 1);
const otherPages = await promiseMap(pages, async page => {
const { instances } = await getTrackedEntities(api, { ...query, page });
return instances;
const { trackedEntities } = await api.tracker.trackedEntities.get({ ...query, page }).getData();
return trackedEntities;
});

return [...firstPage, ..._.flatten(otherPages)];
Expand Down
Loading
Loading