From 5f81770c4fea1b924237445cc6523bd47c01ea52 Mon Sep 17 00:00:00 2001 From: Ramon-Jimenez Date: Thu, 16 Apr 2026 13:20:12 +0200 Subject: [PATCH 1/8] fix country dashboard sharing: restrict access to project users/groups Country dashboards were set to public read+write access (rw------), allowing any user to see all country dashboards regardless of their project assignments. Now sharing is restricted to the aggregated users and user groups from all projects within the country, matching the pattern already used by project dashboards via ProjectSharing. Adds migration 10 to regenerate all existing country dashboards with the corrected sharing settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tasks/10.fix-country-dashboard-sharing.ts | 28 +++++ src/migrations/tasks/index.ts | 1 + src/models/CountryDashboard.ts | 11 +- .../__tests__/data/project-db-metadata.json | 110 +++++++++++++++++- 4 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 src/migrations/tasks/10.fix-country-dashboard-sharing.ts diff --git a/src/migrations/tasks/10.fix-country-dashboard-sharing.ts b/src/migrations/tasks/10.fix-country-dashboard-sharing.ts new file mode 100644 index 00000000..980c9a23 --- /dev/null +++ b/src/migrations/tasks/10.fix-country-dashboard-sharing.ts @@ -0,0 +1,28 @@ +import { Config } from "./../../models/Config"; +import { D2Api, Id } from "../../types/d2-api"; +import { Debug, Migration } from "../types"; +import Project from "../../models/Project"; +import { promiseMap, enumerate } from "../utils"; +import { getConfig } from "../../models/Config"; +import ProjectDashboardSave from "../../models/ProjectDashboardSave"; +import { getProjectIds } from "./common"; + +async function migrate(api: D2Api, debug: Debug): Promise { + const config = await getConfig(api); + const projectIds = await getProjectIds(api, config, debug); + debug(`Projects count: ${projectIds.length}`); + await saveProjectDashboards(api, config, debug, projectIds); +} + +async function saveProjectDashboards(api: D2Api, config: Config, debug: Debug, projectIds: Id[]) { + return promiseMap(enumerate(projectIds), async ([idx, projectId]) => { + const project = await Project.get(api, config, projectId); + const name = `[${project.parentOrgUnit?.displayName}] ${project.name} (${project.id})`; + debug(`Save dashboard (${idx + 1} / ${projectIds.length}): ${name}`); + await new ProjectDashboardSave(project).execute(); + }); +} + +const migration: Migration = { name: "Fix country dashboard sharing", migrate }; + +export default migration; diff --git a/src/migrations/tasks/index.ts b/src/migrations/tasks/index.ts index 351cdeca..1fce735a 100644 --- a/src/migrations/tasks/index.ts +++ b/src/migrations/tasks/index.ts @@ -11,5 +11,6 @@ export async function getMigrationTasks(): Promise { migration(7, (await import("./07.update-to-v2.36")).default), migration(8, (await import("./08.set-integer-people-dataelements")).default), migration(9, (await import("./09.add-last-updated-data")).default), + migration(10, (await import("./10.fix-country-dashboard-sharing")).default), ]; } diff --git a/src/models/CountryDashboard.ts b/src/models/CountryDashboard.ts index a6958ee5..e807f344 100644 --- a/src/models/CountryDashboard.ts +++ b/src/models/CountryDashboard.ts @@ -33,7 +33,7 @@ import { Condition, DashboardSourceMetadata, } from "./ProjectsListDashboard"; -import { D2Sharing, getD2Access } from "./Sharing"; +import { D2Sharing, getD2EntitiesAccess, fullMetadataAccess } from "./Sharing"; type D2VisualizationPayload = PartialPersistedModel; @@ -241,9 +241,14 @@ export default class CountryDashboard { return d2Table ? { ...d2Table, ...chart.extra } : null; } - getSharing(): Partial { + getSharing(): D2Sharing { + const { userAccesses, userGroupAccesses } = this.country.projectsListDashboard.sharing; + return { - publicAccess: getD2Access({ meta: { read: true, write: true } }), + publicAccess: "--------", + externalAccess: false, + userAccesses: getD2EntitiesAccess(userAccesses, fullMetadataAccess), + userGroupAccesses: getD2EntitiesAccess(userGroupAccesses, fullMetadataAccess), }; } } diff --git a/src/models/__tests__/data/project-db-metadata.json b/src/models/__tests__/data/project-db-metadata.json index 188f4f34..e2b8f832 100644 --- a/src/models/__tests__/data/project-db-metadata.json +++ b/src/models/__tests__/data/project-db-metadata.json @@ -1210,7 +1210,27 @@ "y": 90 } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "OiCmorbkHNf", @@ -3112,7 +3132,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "CS7csnUtibF", @@ -3230,7 +3270,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "GqYJDh6asM8", @@ -3378,7 +3438,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "me6p7L8VHXl", @@ -3490,7 +3570,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "uqMTlezGj0I", From 6aeab9b66cb15c5f45f15307782e362ccfca01de Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Sun, 19 Apr 2026 10:40:33 -0500 Subject: [PATCH 2/8] add script to update country dashboards permissions --- src/models/ProjectsList.ts | 6 +- src/pages/projects-list/ProjectsList.tsx | 3 +- .../unique-periods/UniquePeriodsForm.tsx | 16 +- src/scripts/fix-country-dashboard-sharing.ts | 286 ++++++++++++++++++ src/utils/date.ts | 17 +- 5 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 src/scripts/fix-country-dashboard-sharing.ts diff --git a/src/models/ProjectsList.ts b/src/models/ProjectsList.ts index a3cff1cc..2a7d5466 100644 --- a/src/models/ProjectsList.ts +++ b/src/models/ProjectsList.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { TableSorting } from "@eyeseetea/d2-ui-components"; import { D2Api, D2OrganisationUnitSchema, SelectedPick, Id, Pager, Ref } from "../types/d2-api"; import { Config } from "./Config"; import moment, { Moment } from "moment"; @@ -399,3 +398,8 @@ function getOrgUnitsFilter(filters: FiltersForList, currentUser: User) { : userCountryIds; return filterCountryIds ? { "parent.id": { in: filterCountryIds } } : {}; } + +export type TableSorting = { + field: keyof T; + order: "asc" | "desc"; +}; diff --git a/src/pages/projects-list/ProjectsList.tsx b/src/pages/projects-list/ProjectsList.tsx index 7d8d1cf9..9c58993b 100644 --- a/src/pages/projects-list/ProjectsList.tsx +++ b/src/pages/projects-list/ProjectsList.tsx @@ -1,5 +1,4 @@ import _ from "lodash"; -import { TableSorting } from "@eyeseetea/d2-ui-components"; import React, { useCallback } from "react"; import styled from "styled-components"; import ActionButton from "../../components/action-button/ActionButton"; @@ -15,7 +14,7 @@ import { ObjectsList, ObjectsListProps } from "../../components/objects-list/Obj import { useAppContext } from "../../contexts/api-context"; import i18n from "../../locales"; import Project from "../../models/Project"; -import { FiltersForList, ProjectForList } from "../../models/ProjectsList"; +import { FiltersForList, ProjectForList, TableSorting } from "../../models/ProjectsList"; import { useGoTo } from "../../router"; import { Id } from "../../types/d2-api"; import { ActionName, getComponentConfig, UrlState } from "./ProjectsListConfig"; diff --git a/src/pages/unique-periods/UniquePeriodsForm.tsx b/src/pages/unique-periods/UniquePeriodsForm.tsx index 198ba202..7717754e 100644 --- a/src/pages/unique-periods/UniquePeriodsForm.tsx +++ b/src/pages/unique-periods/UniquePeriodsForm.tsx @@ -10,6 +10,7 @@ import { import i18n from "../../locales"; import { Maybe } from "../../types/utils"; import { getErrors } from "../../domain/entities/generic/Errors"; +import { months } from "../../utils/date"; export type UniquePeriodsFormProps = { existingPeriod?: UniqueBeneficiariesPeriodsAttrs; @@ -17,21 +18,6 @@ export type UniquePeriodsFormProps = { onSubmit: (uniquePeriods: UniqueBeneficiariesPeriod) => void; }; -export const months = [ - { value: "1", text: i18n.t("January") }, - { value: "2", text: i18n.t("February") }, - { value: "3", text: i18n.t("March") }, - { value: "4", text: i18n.t("April") }, - { value: "5", text: i18n.t("May") }, - { value: "6", text: i18n.t("June") }, - { value: "7", text: i18n.t("July") }, - { value: "8", text: i18n.t("August") }, - { value: "9", text: i18n.t("September") }, - { value: "10", text: i18n.t("October") }, - { value: "11", text: i18n.t("November") }, - { value: "12", text: i18n.t("December") }, -]; - function getValueByAttribute( value: string, attribute: keyof UniqueBeneficiariesPeriod diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts new file mode 100644 index 00000000..7a4c64fb --- /dev/null +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -0,0 +1,286 @@ +import _ from "lodash"; +import parse from "parse-typed-args"; + +import { D2Api, Id, MetadataPick } from "../types/d2-api"; +import { writeDataFilePath } from "./common"; +import { promiseMap } from "../migrations/utils"; +import { getUid } from "../utils/dhis2"; +import CountryDashboard from "../models/CountryDashboard"; +import { D2Sharing } from "../models/Sharing"; +import { Config, getConfig } from "../models/Config"; + +/* + Update sharing (publicAccess, externalAccess, userAccesses, userGroupAccesses) + on every existing country dashboard and its visualizations, using the sharing + computed by CountryDashboard.getSharing(). + + yarn ts-node src/scripts/fix-country-dashboard-sharing.ts \ + --url="http://server.com" [--auth=user:pass] [--country-ids=id1,id2] [--dry-run] +*/ + +type D2Dashboard = MetadataPick<{ + dashboards: { fields: { $owner: true } }; +}>["dashboards"][number]; + +type D2Visualization = MetadataPick<{ + visualizations: { fields: { $owner: true } }; +}>["visualizations"][number]; + +type D2DashboardItem = D2Dashboard["dashboardItems"][number]; + +interface Country { + readonly id: Id; + readonly displayName: string; +} + +interface SharingComputation { + readonly sharingByCountryId: Readonly>; + readonly failedCountries: Country[]; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); + +async function main() { + const parser = parse({ + opts: { + url: {}, + auth: {}, + "country-ids": {}, + "dry-run": { switch: true }, + }, + }); + const { opts } = parser(process.argv); + const { url, auth, "country-ids": countryIdsRaw, "dry-run": dryRun } = opts; + + const usage = + "fix-country-dashboard-sharing --url= [--auth=user:pass] [--country-ids=id1,id2] [--dry-run]"; + if (!url) { + console.error(usage); + process.exit(1); + } + + const [username, password] = auth ? auth.split(":") : ["", ""]; + const api = new D2Api({ baseUrl: url, auth: { password, username }, agent: {} }); + const config = await getConfig(api); + + const filterIds = countryIdsRaw ? countryIdsRaw.split(",").map(s => s.trim()) : undefined; + + const countries = await getCountries(api, config, filterIds); + console.log(`Countries: ${countries.length}`); + if (countries.length === 0) return; + + const dashboardIds = countries.map(c => getUid("country-dashboard", c.id)); + const dashboards = await fetchDashboards(api, dashboardIds); + console.log(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); + + const vizIds = extractVizIds(dashboards); + const visualizations = await fetchVisualizations(api, vizIds); + console.log(`Visualizations: ${visualizations.length} / ${vizIds.length}`); + + const { sharingByCountryId, failedCountries } = await computeSharingPerCountry( + api, + config, + countries + ); + if (failedCountries.length > 0) { + console.error( + `Sharing computation failed for ${failedCountries.length}/${countries.length} countries: ` + + failedCountries.map(c => `${c.displayName} (${c.id})`).join(", ") + ); + } + + const { updatedDashboards, updatedVisualizations } = applySharing( + dashboards, + visualizations, + countries, + sharingByCountryId + ); + console.log( + `Payload: dashboards=${updatedDashboards.length}, visualizations=${updatedVisualizations.length}` + ); + + const payload = { + dashboards: updatedDashboards, + visualizations: updatedVisualizations, + }; + + if (dryRun) { + writeDataFilePath("sharing-payload", payload); + console.log("Dry run — payload written via writeDataFilePath('sharing-payload')."); + return; + } + + const res = await api.metadata + .post(payload, { importStrategy: "UPDATE", importMode: "VALIDATE" }) + .getData(); + console.log(`Import status: ${res.status}`); + console.log(`Stats: ${JSON.stringify(res.stats)}`); + if (res.status !== "OK") throw new Error(JSON.stringify(res, null, 2)); +} + +async function getCountries( + api: D2Api, + config: Config, + filterIds: Id[] | undefined +): Promise { + const { objects } = await api.models.organisationUnits + .get({ + paging: false, + fields: { id: true, displayName: true }, + filter: { level: { eq: String(config.base.orgUnits.levelForCountries) } }, + }) + .getData(); + + const sorted = _(objects) + .map(c => ({ id: c.id, displayName: c.displayName })) + .sortBy(c => c.displayName) + .value(); + + return filterIds ? sorted.filter(c => filterIds.includes(c.id)) : sorted; +} + +async function fetchChunked(ids: Id[], fetchChunk: (chunk: Id[]) => Promise): Promise { + if (ids.length === 0) return []; + const chunks = await promiseMap(_.chunk(ids, 100), fetchChunk); + return chunks.flat(); +} + +async function fetchDashboards(api: D2Api, ids: Id[]): Promise { + return fetchChunked(ids, async chunk => { + const { dashboards } = await api.metadata + .get({ + dashboards: { + fields: { $owner: true }, + filter: { id: { in: chunk } }, + }, + }) + .getData(); + return dashboards; + }); +} + +async function fetchVisualizations(api: D2Api, ids: Id[]): Promise { + return fetchChunked(ids, async chunk => { + const { visualizations } = await api.metadata + .get({ + visualizations: { + fields: { $owner: true }, + filter: { id: { in: chunk } }, + }, + }) + .getData(); + return visualizations; + }); +} + +function getVizIdFromItem(di: D2DashboardItem): Id | undefined { + const anyDi = di as { + chart?: { id?: Id }; + reportTable?: { id?: Id }; + visualization?: { id?: Id }; + }; + return anyDi.chart?.id ?? anyDi.reportTable?.id ?? anyDi.visualization?.id; +} + +function extractVizIds(dashboards: D2Dashboard[]): Id[] { + return _(dashboards) + .flatMap(d => d.dashboardItems ?? []) + .map(getVizIdFromItem) + .compact() + .uniq() + .value(); +} + +async function computeSharingPerCountry( + api: D2Api, + config: Config, + countries: Country[] +): Promise { + const results = await promiseMap(countries, async country => { + console.debug(`Computing sharing for ${country.displayName} (${country.id})...`); + try { + const cd = await CountryDashboard.build(api, config, country.id); + return { country, sharing: cd.getSharing() }; + } catch (e) { + console.error(`Cannot compute sharing for ${country.displayName} (${country.id}):`, e); + return { country, sharing: null as D2Sharing | null }; + } + }); + + const sharingByCountryId = Object.fromEntries( + results + .filter((r): r is { country: Country; sharing: D2Sharing } => r.sharing !== null) + .map(r => [r.country.id, r.sharing] as const) + ); + const failedCountries = results.filter(r => r.sharing === null).map(r => r.country); + + return { sharingByCountryId, failedCountries }; +} + +function withSharing(obj: T, sharing: D2Sharing): T { + return { + ...obj, + publicAccess: sharing.publicAccess, + externalAccess: sharing.externalAccess, + userAccesses: sharing.userAccesses, + userGroupAccesses: sharing.userGroupAccesses, + }; +} + +function applySharing( + dashboards: D2Dashboard[], + visualizations: D2Visualization[], + countries: Country[], + sharingByCountryId: Readonly> +): { + updatedDashboards: D2Dashboard[]; + updatedVisualizations: D2Visualization[]; +} { + const countryByDashboardId: Readonly> = Object.fromEntries( + countries.map(c => [getUid("country-dashboard", c.id), c.id]) + ); + + const dashboardByVizId: Readonly> = Object.fromEntries( + _(dashboards) + .flatMap(d => + (d.dashboardItems ?? []).map(di => { + const vid = getVizIdFromItem(di); + return vid ? ([vid, d.id] as const) : null; + }) + ) + .compact() + .value() + ); + + const updatedDashboards = _(dashboards) + .map(d => { + const sharing = sharingByCountryId[countryByDashboardId[d.id]]; + if (!sharing) { + console.error(`No sharing computed for dashboard ${d.id} — skipping`); + return undefined; + } + return withSharing(d, sharing); + }) + .compact() + .value(); + + const updatedVisualizations = _(visualizations) + .map(v => { + const dashId = dashboardByVizId[v.id]; + const sharing = sharingByCountryId[countryByDashboardId[dashId]]; + if (!sharing) { + console.error( + `No sharing for visualization ${v.id} (dashboard ${dashId}) — skipping` + ); + return undefined; + } + return withSharing(v, sharing); + }) + .compact() + .value(); + + return { updatedDashboards, updatedVisualizations }; +} diff --git a/src/utils/date.ts b/src/utils/date.ts index 8180d29c..35bb52d7 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,5 +1,5 @@ import moment, { Moment } from "moment"; -import { months } from "../pages/unique-periods/UniquePeriodsForm"; +import i18n from "../locales"; export function toISOString(date: Moment) { return date.format("YYYY-MM-DDTHH:mm:ss"); @@ -58,3 +58,18 @@ export function buildMonthYearFormatDate(dateIsoString: string): string { export function getMonthNameFromNumber(monthNumber: string | number): string { return months.find(month => month.value === monthNumber.toString())?.text || ""; } + +export const months = [ + { value: "1", text: i18n.t("January") }, + { value: "2", text: i18n.t("February") }, + { value: "3", text: i18n.t("March") }, + { value: "4", text: i18n.t("April") }, + { value: "5", text: i18n.t("May") }, + { value: "6", text: i18n.t("June") }, + { value: "7", text: i18n.t("July") }, + { value: "8", text: i18n.t("August") }, + { value: "9", text: i18n.t("September") }, + { value: "10", text: i18n.t("October") }, + { value: "11", text: i18n.t("November") }, + { value: "12", text: i18n.t("December") }, +]; From e1d13abf623a538ba1af22f0a686410a9462bd35 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Sun, 19 Apr 2026 12:50:26 -0500 Subject: [PATCH 3/8] improve logs and add a persist flag --- src/scripts/fix-country-dashboard-sharing.ts | 36 +++++++++----------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts index 7a4c64fb..924d3a8e 100644 --- a/src/scripts/fix-country-dashboard-sharing.ts +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -14,8 +14,8 @@ import { Config, getConfig } from "../models/Config"; on every existing country dashboard and its visualizations, using the sharing computed by CountryDashboard.getSharing(). - yarn ts-node src/scripts/fix-country-dashboard-sharing.ts \ - --url="http://server.com" [--auth=user:pass] [--country-ids=id1,id2] [--dry-run] + npx tsx src/scripts/fix-country-dashboard-sharing.ts \ + --url="http://server.com" [--auth=user:pass] [--country-ids=id1,id2] [--persist] */ type D2Dashboard = MetadataPick<{ @@ -49,14 +49,14 @@ async function main() { url: {}, auth: {}, "country-ids": {}, - "dry-run": { switch: true }, + persist: { switch: true }, }, }); const { opts } = parser(process.argv); - const { url, auth, "country-ids": countryIdsRaw, "dry-run": dryRun } = opts; + const { url, auth, "country-ids": countryIdsRaw, persist } = opts; const usage = - "fix-country-dashboard-sharing --url= [--auth=user:pass] [--country-ids=id1,id2] [--dry-run]"; + "fix-country-dashboard-sharing --url= [--auth=user:pass] [--country-ids=id1,id2] [--persist]"; if (!url) { console.error(usage); process.exit(1); @@ -69,16 +69,16 @@ async function main() { const filterIds = countryIdsRaw ? countryIdsRaw.split(",").map(s => s.trim()) : undefined; const countries = await getCountries(api, config, filterIds); - console.log(`Countries: ${countries.length}`); + console.debug(`Countries: ${countries.length}`); if (countries.length === 0) return; const dashboardIds = countries.map(c => getUid("country-dashboard", c.id)); const dashboards = await fetchDashboards(api, dashboardIds); - console.log(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); + console.debug(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); const vizIds = extractVizIds(dashboards); const visualizations = await fetchVisualizations(api, vizIds); - console.log(`Visualizations: ${visualizations.length} / ${vizIds.length}`); + console.debug(`Visualizations: ${visualizations.length} / ${vizIds.length}`); const { sharingByCountryId, failedCountries } = await computeSharingPerCountry( api, @@ -98,7 +98,7 @@ async function main() { countries, sharingByCountryId ); - console.log( + console.debug( `Payload: dashboards=${updatedDashboards.length}, visualizations=${updatedVisualizations.length}` ); @@ -107,17 +107,13 @@ async function main() { visualizations: updatedVisualizations, }; - if (dryRun) { - writeDataFilePath("sharing-payload", payload); - console.log("Dry run — payload written via writeDataFilePath('sharing-payload')."); - return; - } + writeDataFilePath("sharing-payload", payload); + console.debug("payload saved to disk"); const res = await api.metadata - .post(payload, { importStrategy: "UPDATE", importMode: "VALIDATE" }) + .post(payload, { importStrategy: "UPDATE", importMode: persist ? "COMMIT" : "VALIDATE" }) .getData(); - console.log(`Import status: ${res.status}`); - console.log(`Stats: ${JSON.stringify(res.stats)}`); + console.debug(`Import status: ${JSON.stringify(res.status)}`); if (res.status !== "OK") throw new Error(JSON.stringify(res, null, 2)); } @@ -200,13 +196,13 @@ async function computeSharingPerCountry( countries: Country[] ): Promise { const results = await promiseMap(countries, async country => { - console.debug(`Computing sharing for ${country.displayName} (${country.id})...`); + console.debug(`Generating sharing for ${country.displayName} (${country.id})...`); try { const cd = await CountryDashboard.build(api, config, country.id); return { country, sharing: cd.getSharing() }; } catch (e) { - console.error(`Cannot compute sharing for ${country.displayName} (${country.id}):`, e); - return { country, sharing: null as D2Sharing | null }; + console.error(`Cannot generate sharing for ${country.displayName} (${country.id}):`, e); + return { country, sharing: null }; } }); From b62eeab3c2e93438f92c2093c12708e7b0d92766 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Date: Tue, 21 Apr 2026 13:31:57 -0500 Subject: [PATCH 4/8] get reference to all country dashboards even if there is no projects associated --- src/scripts/fix-country-dashboard-sharing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts index 924d3a8e..9af6519d 100644 --- a/src/scripts/fix-country-dashboard-sharing.ts +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -72,7 +72,7 @@ async function main() { console.debug(`Countries: ${countries.length}`); if (countries.length === 0) return; - const dashboardIds = countries.map(c => getUid("country-dashboard", c.id)); + const dashboardIds = countries.map(c => c.displayName); const dashboards = await fetchDashboards(api, dashboardIds); console.debug(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); @@ -150,7 +150,7 @@ async function fetchDashboards(api: D2Api, ids: Id[]): Promise { .get({ dashboards: { fields: { $owner: true }, - filter: { id: { in: chunk } }, + filter: { name: { in: chunk } }, }, }) .getData(); From bedd5d4700b414d7a03e2ac183a5b6e9c60a3037 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 21 Apr 2026 20:29:18 -0500 Subject: [PATCH 5/8] remove app migration --- .../tasks/10.fix-country-dashboard-sharing.ts | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/migrations/tasks/10.fix-country-dashboard-sharing.ts diff --git a/src/migrations/tasks/10.fix-country-dashboard-sharing.ts b/src/migrations/tasks/10.fix-country-dashboard-sharing.ts deleted file mode 100644 index 980c9a23..00000000 --- a/src/migrations/tasks/10.fix-country-dashboard-sharing.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Config } from "./../../models/Config"; -import { D2Api, Id } from "../../types/d2-api"; -import { Debug, Migration } from "../types"; -import Project from "../../models/Project"; -import { promiseMap, enumerate } from "../utils"; -import { getConfig } from "../../models/Config"; -import ProjectDashboardSave from "../../models/ProjectDashboardSave"; -import { getProjectIds } from "./common"; - -async function migrate(api: D2Api, debug: Debug): Promise { - const config = await getConfig(api); - const projectIds = await getProjectIds(api, config, debug); - debug(`Projects count: ${projectIds.length}`); - await saveProjectDashboards(api, config, debug, projectIds); -} - -async function saveProjectDashboards(api: D2Api, config: Config, debug: Debug, projectIds: Id[]) { - return promiseMap(enumerate(projectIds), async ([idx, projectId]) => { - const project = await Project.get(api, config, projectId); - const name = `[${project.parentOrgUnit?.displayName}] ${project.name} (${project.id})`; - debug(`Save dashboard (${idx + 1} / ${projectIds.length}): ${name}`); - await new ProjectDashboardSave(project).execute(); - }); -} - -const migration: Migration = { name: "Fix country dashboard sharing", migrate }; - -export default migration; From 337d2c3b7cfae6c02085c23f43acda88dd71e598 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 21 Apr 2026 21:03:27 -0500 Subject: [PATCH 6/8] remove country migration --- src/migrations/tasks/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/migrations/tasks/index.ts b/src/migrations/tasks/index.ts index 1fce735a..351cdeca 100644 --- a/src/migrations/tasks/index.ts +++ b/src/migrations/tasks/index.ts @@ -11,6 +11,5 @@ export async function getMigrationTasks(): Promise { migration(7, (await import("./07.update-to-v2.36")).default), migration(8, (await import("./08.set-integer-people-dataelements")).default), migration(9, (await import("./09.add-last-updated-data")).default), - migration(10, (await import("./10.fix-country-dashboard-sharing")).default), ]; } From 1e06a92ada44e35e7770d09b33ce3615247290ea Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 22 Apr 2026 11:17:47 -0500 Subject: [PATCH 7/8] validate if org. unit match a country dashboard by name and attribute value --- src/scripts/fix-country-dashboard-sharing.ts | 37 +++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts index 9af6519d..449048d3 100644 --- a/src/scripts/fix-country-dashboard-sharing.ts +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -31,6 +31,7 @@ type D2DashboardItem = D2Dashboard["dashboardItems"][number]; interface Country { readonly id: Id; readonly displayName: string; + readonly dashboardId: Id; } interface SharingComputation { @@ -125,13 +126,41 @@ async function getCountries( const { objects } = await api.models.organisationUnits .get({ paging: false, - fields: { id: true, displayName: true }, - filter: { level: { eq: String(config.base.orgUnits.levelForCountries) } }, + fields: { + id: true, + displayName: true, + attributeValues: { attribute: { id: true, name: true }, value: true }, + }, + filter: { level: { in: ["2", "3"] } }, }) .getData(); - const sorted = _(objects) - .map(c => ({ id: c.id, displayName: c.displayName })) + const notCreatedByDmApp = _(objects) + .map((ou): Country | undefined => { + const createdByDmApp = ou.attributeValues.find( + av => av.attribute.id === config.attributes.createdByApp.id + ); + + const dashboardId = ou.attributeValues.find( + av => av.attribute.id === config.attributes.projectDashboard.id + ); + + const createdValue = createdByDmApp?.value === "true"; + const dashboardValue = dashboardId?.value; + + if (createdValue) return undefined; + if (!dashboardValue) return undefined; + + return { + id: ou.id, + displayName: ou.displayName, + dashboardId: dashboardValue, + }; + }) + .compact() + .value(); + + const sorted = _(notCreatedByDmApp) .sortBy(c => c.displayName) .value(); From 035bd581976ca7c72f3fd5ea0cc73dccae0a9647 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 22 Apr 2026 12:30:35 -0500 Subject: [PATCH 8/8] get dashboard id from attribute in org. units --- src/scripts/fix-country-dashboard-sharing.ts | 31 +++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts index 449048d3..6093740e 100644 --- a/src/scripts/fix-country-dashboard-sharing.ts +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -73,7 +73,7 @@ async function main() { console.debug(`Countries: ${countries.length}`); if (countries.length === 0) return; - const dashboardIds = countries.map(c => c.displayName); + const dashboardIds = countries.map(c => c.dashboardId); const dashboards = await fetchDashboards(api, dashboardIds); console.debug(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); @@ -86,6 +86,7 @@ async function main() { config, countries ); + if (failedCountries.length > 0) { console.error( `Sharing computation failed for ${failedCountries.length}/${countries.length} countries: ` + @@ -99,6 +100,7 @@ async function main() { countries, sharingByCountryId ); + console.debug( `Payload: dashboards=${updatedDashboards.length}, visualizations=${updatedVisualizations.length}` ); @@ -114,7 +116,9 @@ async function main() { const res = await api.metadata .post(payload, { importStrategy: "UPDATE", importMode: persist ? "COMMIT" : "VALIDATE" }) .getData(); + console.debug(`Import status: ${JSON.stringify(res.status)}`); + if (res.status !== "OK") throw new Error(JSON.stringify(res, null, 2)); } @@ -131,10 +135,14 @@ async function getCountries( displayName: true, attributeValues: { attribute: { id: true, name: true }, value: true }, }, + // after org. unit reorganization, some country dashboards might be attached to level 3 org. units + // instead of level 2 filter: { level: { in: ["2", "3"] } }, }) .getData(); + // exclude org. units created by the app + // and without reference to a country dashboard const notCreatedByDmApp = _(objects) .map((ou): Country | undefined => { const createdByDmApp = ou.attributeValues.find( @@ -176,12 +184,7 @@ async function fetchChunked(ids: Id[], fetchChunk: (chunk: Id[]) => Promise { return fetchChunked(ids, async chunk => { const { dashboards } = await api.metadata - .get({ - dashboards: { - fields: { $owner: true }, - filter: { name: { in: chunk } }, - }, - }) + .get({ dashboards: { fields: { $owner: true }, filter: { id: { in: chunk } } } }) .getData(); return dashboards; }); @@ -190,12 +193,7 @@ async function fetchDashboards(api: D2Api, ids: Id[]): Promise { async function fetchVisualizations(api: D2Api, ids: Id[]): Promise { return fetchChunked(ids, async chunk => { const { visualizations } = await api.metadata - .get({ - visualizations: { - fields: { $owner: true }, - filter: { id: { in: chunk } }, - }, - }) + .get({ visualizations: { fields: { $owner: true }, filter: { id: { in: chunk } } } }) .getData(); return visualizations; }); @@ -307,5 +305,10 @@ function applySharing( .compact() .value(); - return { updatedDashboards, updatedVisualizations }; + return { + updatedDashboards: _(updatedDashboards) + .uniqBy(dashboard => dashboard.id) + .value(), + updatedVisualizations, + }; }