Skip to content

fix: [DHIS2-17544] enforce org unit opening/closing dates in Capture#4619

Draft
karolinelien wants to merge 7 commits into
masterfrom
DHIS2-17544-orgunit-dates
Draft

fix: [DHIS2-17544] enforce org unit opening/closing dates in Capture#4619
karolinelien wants to merge 7 commits into
masterfrom
DHIS2-17544-orgunit-dates

Conversation

@karolinelien

@karolinelien karolinelien commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What & why

DHIS2-17544 — Org unit opening/closing dates were never enforced in the Capture app, so users could enroll people and add/edit events with dates outside the selected org unit's open/close range. (Verified the dhis2-core tracker importer doesn't enforce this either — see Follow-up below.)

What this does

Enforces the range — both bounds inclusive, day granularity, calendar-safe (handles non-Gregorian calendars) — on the date fields that represent activity at an org unit, each validated against its own org unit:

Field Flows
Enrollment date new + edit
Event occurred date new + edit
Event scheduled/due date scheduling
Enrollment transfer hard-block when the enrollment date is outside the destination org unit's range

Each constrained field disables out-of-range dates in the calendar picker and adds a blocking validator (existing "no future date" limits are combined with the closing date). Org units with no opening/closing date leave that side unconstrained.

Key decisions

  • Hard block on both new and existing records. Existing out-of-range records can't be saved until the date is corrected — a deliberate product call (documented risk below).
  • Incident date is intentionally NOT constrained — it doesn't represent activity at the org unit (can legitimately be a future/onset date).
  • Transfer checks the enrollment date only against the destination; blocks via a disabled button + error notice.

Implementation

  • New getWithinOrgUnitDateRangeValidator + isIsoDateWithinOrgUnitRange (shared boundary core) in form validators.
  • New shared getOrgUnitCalendarBounds helper for calendar min/max.
  • calendarMin plumbed through the shared date component to @dhis2/ui CalendarInput.
  • CoreOrgUnit type + both org-unit fetch paths now request openingDate,closedDate; event/transfer flows enrich their selected org unit via useCoreOrgUnit.

Testing

  • Unit tests for the validator + ISO helper (within range, both boundaries inclusive, single-bound, empty/no-dates).
  • tsc --noEmit: clean. ESLint: clean (local linebreak-style noise is the Windows CRLF checkout only).

Known risks (deliberate)

  1. Existing out-of-range records are uneditable until the date is corrected.
  2. A blocked transfer has no in-app remediation — only an admin widening the destination org unit's dates enables it.

Not in this PR

  • Backend companion (dhis2-core DateValidator org-unit-range check) — the importer currently accepts out-of-range dates, so API/import/Android still bypass the rule. To be filed separately.
  • P2: inline working/line-list date edits; disabling fully-closed org units in selection.

AI Assisted

Org unit opening/closing dates were never enforced in the Capture app,
so users could enroll people and add/edit events with dates outside the
selected org unit's open/close range.

Enforce the range (both bounds inclusive, day granularity, calendar-safe)
on the date fields that represent activity at an org unit, each validated
against its own org unit:

- Enrollment date (new + edit)
- Event occurred date (new + edit)
- Event scheduled/due date
- Enrollment transfer: hard-block when the enrollment date falls outside
  the destination org unit's range

Each constrained field disables out-of-range dates in the calendar and
adds a blocking validator. Org units without opening/closing dates leave
that side unconstrained.

The incident date is intentionally not constrained, as it does not
represent activity at the org unit (it may legitimately be a future or
historical onset date).

AI Assisted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
},
{
validator: getWithinOrgUnitDateRangeValidator(orgUnit),
errorMessage: '',

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.

Something is not right here. I am guessing there should have been a i18n string for error message here as well if the date is outside range.

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.

Claude: "Good question — it's already internationalized, just not as a static container string. The message is built dynamically inside the validator (getWithinOrgUnitDateRangeValidator → buildErrorMessage), where every variant goes through i18n.t(...) and interpolates the org unit's opening/closing dates and label. That's why the validator container's static errorMessage is intentionally left ''.

The framework picks the dynamic message over the static one: getValidationError in dataEntryField.utils.ts uses result.errorMessage || validatorContainer.errorMessage, so the static '' is only a fallback for when a validator returns no message — and this one always returns a non-empty, translated message on failure. It's the same pattern validateNotExpired already uses. I've added a clarifying comment at the container so this isn't mistaken for a missing string. Let me know if you'd still prefer a static string as well."

karolinelien and others added 6 commits June 19, 2026 19:50
- getOrgUnitClosingCalendarMax: replace reduce() (no initial value) with a
  sort to pick the earliest ISO bound (S6959, S7766)
- getWithinOrgUnitDateRangeValidator: log caught date-parse errors instead
  of silently swallowing them (S2486)
- test: use optional chaining (S6582)

AI Assisted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unit label

Review fixes:
- getOrgUnitCalendarBounds: normalize the opening date the same way as the
  closing date (strip time component) to avoid a timezone day-shift in the
  calendar's min bound
- extract a shared useOrgUnitWithDates hook, removing the duplicated org-unit
  date-enrichment across the new-event, edit-event and schedule containers
  (also memoized to avoid feeding the form engine an unstable prop)
- export a single OrgUnitDateRange type instead of declaring it three times
- add unit tests for getOrgUnitCalendarBounds and a production calendar-id case

Custom labels (DHIS2-16610):
- the date-range error messages and the transfer block notice now use the
  program's configured "organisation unit" label when one is set, falling back
  to the default term otherwise

AI Assisted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fallback

- getOrgUnitClosingCalendarMax: give sort() an explicit localeCompare compare
  function (SonarQube typescript:S2871)
- document why the org-unit date-range validator containers use an empty static
  errorMessage (the message is built dynamically by the validator, interpolating
  the org unit's dates and label) — addressing review feedback

AI Assisted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the org unit opening/closing-date enforcement into the event-program
"new event" flow (SingleEventRegistrationEntry), which was previously the only
add/edit date flow left unenforced. The org unit is selected within the form,
so the field value is enriched with its dates at the redux layer via
useOrgUnitWithDates; the occurred-date field gets calendar bounds and the
blocking within-range validator, matching the tracker flows.

AI Assisted
- buildErrorMessage: truncate time component before calendar conversion,
  symmetric with isoToPlainDate / getOrgUnitCalendarBounds (no timezone day-shift)
- getOrgUnitClosingCalendarMax: sort the local array in place (drop redundant copy)
- ScheduleDate: simplify the org-unit range check (the !== true guard already narrows)
- useCoreOrgUnit: document that openingDate/closedDate must stay in the fetched fields
  or cached org units fail open
- tests: add the openingDate == closedDate one-day-window case and a non-Gregorian
  (nepali) case exercising the active-calendar input path

AI Assisted
- getOrgUnitCalendarBounds: sort as a standalone statement (S4043)
- single event DataEntry container: swap negated ternary for the error prop (S7735)

AI Assisted
@sonarqubecloud

Copy link
Copy Markdown

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.

2 participants