Skip to content

add openspec config, dataApprovals implementation and unit/integration test#186

Open
eperedo wants to merge 8 commits intofeature/tracker-orgunitparams-42-869beeny2from
feature/ai-coding-dojo
Open

add openspec config, dataApprovals implementation and unit/integration test#186
eperedo wants to merge 8 commits intofeature/tracker-orgunitparams-42-869beeny2from
feature/ai-coding-dojo

Conversation

@eperedo
Copy link
Copy Markdown
Contributor

@eperedo eperedo commented Apr 22, 2026

📌 References

📝 Implementation

📹 Screenshots/Screen capture

🔥 Notes to the tester

@eperedo eperedo requested a review from gqcorneby April 22, 2026 22:03
Copy link
Copy Markdown
Contributor

@gqcorneby gqcorneby left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! I just have minor comments and then I ask claude to do some verification and pointed out a few potential corrections

  1. There's still one openspec left unarchived. I think archive timing is something that should be discussed, just pointing it out here because there 2 already archived :)
  2. Maybe we can add a section in the PR to note down breaking changes. I think there are a few mentioned in openspec files so maybe these would be good to highlight in the PR description
  3. Nice to have tests here! A small update would be to follow convention from other projects move tests and integration tests inside __tests__
src/api/
  dataApprovals.ts
  trackerEvents.ts
  __tests__/
    common.spec.ts
    dataApprovals.spec.ts
    integration/
      trackerEnrollments.spec.ts
      trackerEvents.spec.ts
      trackerTrackedEntities.spec.ts

Comment thread src/api/dataApprovals.ts Outdated

export interface DataApprovalByCategoryOptionCombo {
readonly id: Id;
readonly level?: Id;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is typed as an Id but, after verifying the response, it should be an object.
readonly level?: { readonly id?: Id; readonly level?: number; readonly name?: string }

https://play.im.dhis2.org/stable-2-42-4/api/dataApprovals/categoryOptionCombos?wf=rIUL3hYOjJc&pe=20240
1&ou=O6uvpzGd5pu

 [                                             
      {
          "level": {},
          "ou": "O6uvpzGd5pu",
          "permissions": {
              "mayApprove": false,
              "mayUnapprove": false,                                                                       
              "mayAccept": false,
              "mayUnaccept": false,                                                                        
              "mayReadData": true                                                        
          },                                    
          "accepted": false,
          "id": "HllvX50cXC0",                                                                             
          "ouName": "Bo"
      }                                                                                                    
  ]    

Comment thread src/api/dataApprovals.ts Outdated
approvals: { ou: Id; aoc: Id }[];
}>;

export interface DataApprovalStatus {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type omits mayReadData, but after verifying the response it is always returned. Add:

readonly mayReadData: boolean;

https://play.im.dhis2.org/stable-2-42-4/api/dataApprovals?wf=rIUL3hYOjJc&pe=202401&ou=O6uvpzGd5pu

{
    "mayApprove": false,
    "mayUnapprove": false,
    "mayAccept": false,
    "mayUnaccept": false,
    "mayReadData": true,
    "state": "UNAPPROVED_WAITING"
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property is not in the documentation. I'll included anyway because it's been used in our apps. Thanks!

image

Comment thread src/api/dataApprovals.ts Outdated

export type DataApprovalState = (typeof dataApprovalStates)[number];

type WorkflowOrDataSet<T extends { wf?: unknown; ds?: unknown }> = RequireAtLeastOne<
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequireAtLeastOne requires one of the keys but permits both. The design.md (§2) claims "if both are passed, both are forwarded — DHIS2 resolves the ambiguity server-side (workflow wins)". Verified against the live server: that is wrong. When both are present, ds wins and wf is ignored. A consumer who passes both thinking wf is authoritative silently gets a different workflow's approval state, with no type error and no runtime error.

Suggested fix — discriminated union:

type ApprovalSelector =
    | { wf: Id; ds?: never; pe: string; ou: Id; aoc?: Id }
    | { wf?: never; ds: Id; pe: string; ou: Id; aoc?: Id };

Same treatment for BulkApprovalSelector and BulkApprovalPayload.

Reproduction

Requires one dataset to be attached to a different workflow than the one passed as wf. The default play state has only one workflow (rIUL3hYOjJc) and no dataset attached to it besides pBOMPrpg1QX, so the test needs a second workflow that maps through Child Health (BfMAe6Itzgt). All four steps below are reversible; step 4 restores the play server.

# 1. create a second workflow
curl -s -u 'admin:district' -X POST \
  -H 'Content-Type: application/json' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovalWorkflows' \
  -d '{"name":"Test Review Workflow","periodType":"Monthly","dataApprovalLevels":[{"id":"VS3XyxNvrer"}]}'
# → 201 Created, uid: S5hy9kO44OL  (yours will differ — substitute in the next step)

# 2. attach it to Child Health dataset (BfMAe6Itzgt)
curl -s -u 'admin:district' -X PATCH \
  -H 'Content-Type: application/json-patch+json' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataSets/BfMAe6Itzgt' \
  -d '[{"op":"add","path":"/workflow","value":{"id":"S5hy9kO44OL"}}]'

# 3. the three queries that expose the override
# 3a. wf only  →  baseline for rIUL3hYOjJc
curl -s -u 'admin:district' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovals?wf=rIUL3hYOjJc&pe=202401&ou=O6uvpzGd5pu'
# → {"state":"UNAPPROVED_WAITING","mayApprove":false,...}

# 3b. ds only  →  baseline for the Test workflow (S5hy9kO44OL)
curl -s -u 'admin:district' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovals?ds=BfMAe6Itzgt&pe=202401&ou=O6uvpzGd5pu'
# → {"state":"UNAPPROVED_READY","mayApprove":true,...}

# 3c. BOTH  →  response matches 3b, NOT 3a
curl -s -u 'admin:district' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovals?wf=rIUL3hYOjJc&ds=BfMAe6Itzgt&pe=202401&ou=O6uvpzGd5pu'
# → {"state":"UNAPPROVED_READY","mayApprove":true,...}   ← ds silently wins

# 4. cleanup — restore play to original state
curl -s -u 'admin:district' -X PATCH \
  -H 'Content-Type: application/json-patch+json' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataSets/BfMAe6Itzgt' \
  -d '[{"op":"remove","path":"/workflow"}]'

curl -s -u 'admin:district' -X PATCH \
  -H 'Content-Type: application/json-patch+json' \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovalWorkflows/S5hy9kO44OL' \
  -d '[{"op":"replace","path":"/dataApprovalLevels","value":[]}]'

curl -s -u 'admin:district' -X DELETE \
  'https://play.im.dhis2.org/stable-2-42-4/api/dataApprovalWorkflows/S5hy9kO44OL'

@eperedo eperedo requested a review from tokland April 23, 2026 11:05
Copy link
Copy Markdown
Contributor

@tokland tokland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sane to me. I added some notes (non-blocking), just for discussion.

@@ -0,0 +1,136 @@
## Context

The `/api/dataApprovals` and `/api/dataAcceptances` surface in DHIS2 v42 is [documented here](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-242/data-approval.html#data-approval). It covers four concerns over the same selector tuple `(workflow|dataSet, period, orgUnit, attributeOptionCombo)`:
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition of using the documentation, we have the OpenAPI specification

$ curl-play 'https://play.im.dhis2.org/dev-2-42/api/openapi.json' | tee openapi.json | jq -r '.paths | keys[]' | grep dataApprovals
/api/dataApprovals/
/api/dataApprovals/approvals
/api/dataApprovals/categoryOptionCombos
/api/dataApprovals/dataAcceptances
/api/dataApprovals/dataAcceptances/acceptances
/api/dataApprovals/dataAcceptances/unacceptances
/api/dataApprovals/multiple
/api/dataApprovals/status
/api/dataApprovals/unapprovals

$ cat openapi.json | jq '.paths["/api/dataApprovals/approvals"]' | head -n10
{
  "get": {
    "operationId": "DataApproval.getMultipleApprovalPermissions",
    "parameters": [
      {
        "name": "aoc",
        "in": "query",
        "schema": {
          "type": "array",
          "items": {

I'm not sure what the best approach would be - I imagine Claude can draw insights from both sources.

A lot of possibilities open up from here, but they're out of scope for this exploratory PR; it's really another project in itself.

For example, just as we have an auto-generated schemas.ts for entities (generated from /api/schemas), we could have an auto-generated, fully typed endpoints.ts (generated from /api/openapi.json). Our code would then do things like get("/api/dataApprovals/approvals", params), where params would be fully typed because it would come from the auto-generated types.

From there, a lot of practical decisions follow: the structure of the endpoints (flat or nested, as we have now), backward compatibility, and how to integrate the custom logic we've added — like our fully type-safe, nested fields metadata request, which we'd definitely want to keep.

### 7. Testing strategy

- File: `src/api/dataApprovals.test.ts`, colocated next to the module.
- Approach: `getMockApiFromClass(D2Api)` from `src/testing.ts` produces a `D2Api` + `axios-mock-adapter` pair.
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method was meant to be used by applications, not d2-api itself. (And I don't think we are really using it in the projects either).


#### Scenario: Bulk unaccept posts to unacceptances
- **WHEN** called with `{ wf: ["w1"], pe: ["201601"], approvals: [{ ou: "o1", aoc: "a1" }] }`
- **THEN** the request is `POST /dataAcceptances/unacceptances` with that JSON body
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a exploratory project, so I'll make also exploratory comments :)

In the particular case of an API — where the source of truth isn't what we want to do but what the API provides — maybe it should be enough for the spec to point to some document (plus OpenAPI in the future)?

In other words: I'm quite worried about having duplicated knowledge/goals scattered all over the place.

const AOC = "HllvX50cXC0";
const AOC_2 = "ranftQIH5M9";

function getMockApi(): { api: D2Api; mock: MockAdapter } {
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So Claude decided he didn't want to use our getMockApiFromClass either :)

Comment thread src/testing.ts
program: "IpHINAT79UW",
trackedEntityType: "nEenWmSyUEp",
orgUnit: "DiszpKrYNg8",
} as const;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor, the AI tends to overuse as const.

Comment thread .gitignore
# claude
.claude/settings.local.json

.DS_Store No newline at end of file
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EOF missing (maybe we should add some comment to some AI file)

Comment thread .nvmrc
@@ -1 +1 @@
v18.14.2
v22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about the granularity. When asked, Claude seems to prefer the full version, for reproducibility.

Comment thread package.json
"prerelease": "yarn build",
"release": "bash scripts/publish.sh",
"test": "echo no-op"
"test": "yarn test:unit && yarn test:integration",
Copy link
Copy Markdown
Contributor

@tokland tokland Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's debatable whether the base yarn test - which will be the one executed in CI - should include the integration tests, since a problem with the Play server would mean red flags on PRs. On the other hand, if it's a manual-only action, it may end up being ignored. So I don't have a strong opinion, just raising the subject.

@eperedo eperedo requested a review from gqcorneby April 30, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants