diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index cf55b4ecb..524e5c749 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -1,7 +1,7 @@ name: Check format of code base on: pull_request: - types: [opened, synchronize] + types: [opened, synchronize, reopened] jobs: premerge: diff --git a/.github/workflows/build.yml b/.github/workflows/promote-dev.yml similarity index 67% rename from .github/workflows/build.yml rename to .github/workflows/promote-dev.yml index 79a4c83be..77a79e3e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/promote-dev.yml @@ -1,14 +1,34 @@ # Build and Deploy to dev env. -# Trigger with tag dev -# Connected with repo environment 'dev' -name: OpenShift Build and Deploy to Dev with OWSAP ZAP SCAN +# Trigger with branch dev-env or manual dispatch + +# Example Scenarios +# Automatic Deployment: +# Someone pushes to dev-env +# Workflow triggers automatically +# github.event.inputs.ref is empty +# Code is checked out from github.ref (dev-env branch) +# Manual Deployment from Feature Branch: +# User manually triggers workflow +# Selects "feature/feature-name" in the ref input +# Code is checked out from "feature/feature-name" +# Deployment proceeds with that code + +name: OpenShift Deploy/Promotion to Dev with OWSAP ZAP SCAN on: + workflow_dispatch: + inputs: + reason: + description: 'Reason for manual deployment' + required: true + default: 'Manual dev deployment' + ref: + description: 'Branch to deploy (default: dev-env)' + required: false + default: 'dev-env' push: branches: - - master - tags: - - dev + - dev-env env: CLUSTER: https://api.silver.devops.gov.bc.ca:6443 @@ -25,6 +45,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref || github.ref }} - name: Install OpenShift CLI uses: redhat-actions/openshift-tools-installer@v1 @@ -46,19 +68,28 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + fetch-depth: 0 - name: Install OpenShift CLI uses: redhat-actions/openshift-tools-installer@v1 with: oc: latest + - name: Get previous commit + id: get-prev-commit + run: echo "prev_commit=$(git rev-parse HEAD^)" >> $GITHUB_OUTPUT + - uses: dorny/paths-filter@v2 id: changes with: filters: | src: - 'openshift/**' - base: ${{ github.ref }} + # github.event.before is the SHA of the commit before the push event (only available during push events) + # steps.get-prev-commit.outputs.prev_commit contains the SHA of the parent commit (one before the current commit) + base: ${{ github.event.before || steps.get-prev-commit.outputs.prev_commit }} - name: Dry run - Dev env: OS_NAMESPACE_SUFFIX: dev @@ -85,17 +116,27 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + fetch-depth: 0 - name: Install OpenShift CLI uses: redhat-actions/openshift-tools-installer@v1 with: oc: latest + + - name: Get previous commit + id: get-prev-commit + run: echo "prev_commit=$(git rev-parse HEAD^)" >> $GITHUB_OUTPUT + - uses: dorny/paths-filter@v2 id: changes with: filters: | src: - 'openshift/**' - base: ${{ github.ref}} + # github.event.before is the SHA of the commit before the push event (only available during push events) + # steps.get-prev-commit.outputs.prev_commit contains the SHA of the parent commit (one before the current commit) + base: ${{ github.event.before || steps.get-prev-commit.outputs.prev_commit }} - name: Apply Changes env: OS_NAMESPACE_SUFFIX: dev @@ -115,6 +156,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref || github.ref }} - name: Install OpenShift CLI uses: redhat-actions/openshift-tools-installer@v1 with: diff --git a/.github/workflows/promote-test.yml b/.github/workflows/promote-test.yml index e09c6234c..45422c889 100644 --- a/.github/workflows/promote-test.yml +++ b/.github/workflows/promote-test.yml @@ -1,12 +1,25 @@ # Promotion to test env. -# Trigger with tag push -# Connected with repo environment 'test' +# Trigger with manual dispatch only +# +# Deployment Process: +# User manually triggers workflow +# Provides ticket number in the reason field +# Workflow checks for OpenShift config changes +# Requires approval from environment protection rules +# Deploys the selected branch to test environment name: OpenShift Deploy/Promotion to Test on: - push: - tags: - - test + workflow_dispatch: + inputs: + reason: + description: 'Reason for deployment to test (include ticket number)' + required: true + default: 'BCMOHAM-XXXXX: Test deployment' + ref: + description: 'Branch to deploy (default: test-env)' + required: false + default: 'test-env' env: CLUSTER: https://api.silver.devops.gov.bc.ca:6443 @@ -21,6 +34,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref }} + fetch-depth: 0 - name: Cache OpenShift CLI id: cache-oc @@ -44,13 +60,17 @@ jobs: - name: Verify OpenShift CLI installation run: oc version + - name: Get previous commit + id: get-prev-commit + run: echo "prev_commit=$(git rev-parse HEAD^)" >> $GITHUB_OUTPUT + - uses: dorny/paths-filter@v2 id: changes with: filters: | src: - 'openshift/**' - base: 'refs/tags/test' + base: ${{ steps.get-prev-commit.outputs.prev_commit }} #The commit right before the current commit - name: Dry run - Test env: @@ -77,13 +97,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref }} + fetch-depth: 0 + + - name: Get previous commit + id: get-prev-commit + run: echo "prev_commit=$(git rev-parse HEAD^)" >> $GITHUB_OUTPUT + - uses: dorny/paths-filter@v2 id: changes with: filters: | src: - 'openshift/**' - base: 'refs/tags/test' + base: ${{ steps.get-prev-commit.outputs.prev_commit }} #The commit right before the current commit + - name: Apply Changes env: OS_NAMESPACE_SUFFIX: test @@ -103,6 +132,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref }} - name: Cache OpenShift CLI id: cache-oc diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 7755a633a..3126db27f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -3,6 +3,8 @@ on: push: branches: - master + - dev-env + - test-env pull_request: types: [opened, synchronize, reopened] jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 263db0b99..17f28b774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,8 @@ on: pull_request: branches: + - dev-env + - test-env - master env: KEYCLOAK_LOCAL_USERNAME: 'test-admin' diff --git a/.gitignore b/.gitignore index 97ff29d0f..d7851a7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ server/*.pdf server/pdfs/* server/db/.migrate server/build +server/scripts/output # Misc. Dockerrun.aws.json diff --git a/Makefile b/Makefile index c15933dd7..3f5ca28b8 100644 --- a/Makefile +++ b/Makefile @@ -162,27 +162,22 @@ local-kc-arm-down: @echo "Stopping local app container" @docker-compose -f docker-compose.arm.test.yml down --remove-orphans -# Git Tagging Aliases +# Local Scripts +local-export-business-bceid-has: + @npx ts-node ./server/scripts/export-user-ha.ts -tag-dev: -ifdef ticket - @git tag -fa dev -m "Deploy $(ticket) to DEV env" -else - @echo -e '\nTicket name missing - Example :: make tag-dev ticket=HCAP-ABC \n' - @echo -e 'Falling Back to using branch name \n' - @git tag -fa dev -m "Deploy $(git rev-parse --abbrev-ref HEAD) to DEV env" -endif - @git push --force origin refs/tags/dev:refs/tags/dev +local-export-all-users-has: + @npx ts-node ./server/scripts/export-user-ha.ts --all -tag-test: +# Branch-based deployment commands +deploy-to-dev: #deploy the code on current branch to DEV env via dev-env branch ifdef ticket - @git tag -fa test -m "Deploy $(ticket) to TEST env" + @echo "Deploying current branch to DEV with ticket $(ticket)" + @CURRENT_BRANCH=$(shell git rev-parse --abbrev-ref HEAD) && \ + git push origin $$CURRENT_BRANCH:dev-env -f else - @echo -e '\nTicket name missing - Example :: make tag-test ticket=HCAP-ABC \n' - @echo -e 'Falling Back to using branch name\n' - @git tag -fa test -m "Deploy $(git rev-parse --abbrev-ref HEAD) to TEST env" + @echo -e '\nTicket name missing - Example :: make deploy-to-dev ticket=BCMOHAM-12345 \n' endif - @git push --force origin refs/tags/test:refs/tags/test tag-prod: ifdef ticket diff --git a/client/src/utils/keycloak-util.js b/client/src/utils/keycloak-util.js index 287947108..fe1d17ac4 100644 --- a/client/src/utils/keycloak-util.js +++ b/client/src/utils/keycloak-util.js @@ -6,7 +6,7 @@ * @param idpHint keycloak idp hint */ export const createCustomLoginUrl = (kcInstance, route, idpHint) => { - const idps = ['idir', 'bceid_business']; + const idps = ['idir', 'bceid_business', 'moh_idp']; const loginUrl = kcInstance.createLoginUrl({ idpHint, diff --git a/docs/deployment.md b/docs/deployment.md index 3f9ffbf81..7d87efcde 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,5 @@ # Deployment - ## OpenShift Application The Dockerized application is deployed to OpenShift using Makefile targets and YAML templates defined in the `openshift` directory. @@ -19,6 +18,62 @@ Route | Exposes a service to the Internet. Routes differ from services in that t Deployment Config | Defines how a new version of an application is to be deployed. Additionally, triggers for redeployment are defined within this object. For the HCAP application, we've used a rolling deployment triggered by new images pushed to the image stream and tagged with the `latest` tag. Secret |Defines values that can be used by pods within in the same namespace. While there are no secrets defined in our server application, there is a reference to a secret defined by the [MongoDB database template](openshift/mongo.yml). In order for the server to access the DB, it must be provided with `MONGODB_DATABASE` and `MONGODB_URI` environment variables. The definition for these environment variables can be found in the [server deployment config template](openshift/server.dc.yml). Note that they are referencing the `${APP_NAME}-mongodb` (resolves to `hcap-mongodb`) secret and the `mongo-url` and `database` keys within this secret. +## Deployment Process + +The application uses a branch-based deployment strategy for development and test environments, and a tag-based approach for production. + +### Development Environment + +Deployments to the development environment can be triggered by these 3 approaches: + +1. Creating and merging a PR to the `dev-env` branch +2. Manually triggering the "OpenShift Deploy/Promotion to Dev" workflow in GitHub Actions +3. Using the Makefile command for quick deployments without a PR: + +```bash +# Deploy your current branch to dev +make deploy-to-dev ticket=BCMOHAM-12345 +``` + +This command will: +- Get your current branch name +- Force push your current branch to the remote `dev-env` branch +- Trigger the GitHub Actions workflow for deployment to dev + +### Test Environment + +Deployments to the test environment follow a more controlled process: + +1. Create a PR from `dev-env` to `test-env` +2. Get the PR reviewed and approved by the team +3. Merge the approved PR to update the `test-env` branch +4. Go to GitHub Actions +5. Select "OpenShift Deploy/Promotion to Test" workflow +6. Click "Run workflow" +7. Enter the ticket number (e.g., BCMOHAM-12345) in the reason field +8. Select the branch (`test-env`) +9. Submit the workflow +10. Wait for environment approval and deployment completion + +Test deployments require: +- The ticket number +- PR approval from authorized team members +- Manual workflow trigger with proper documentation +- Final environment approval in GitHub Actions + +### Production Environment (TODO) + +Production deployments use a tag-based approach: + +```bash +# Use the Makefile command +make tag-prod ticket=HCAP-123 +``` + +This command will: +- Create a tag named `prod` pointing to your current commit +- Push it to the remote repository +- Trigger the GitHub Actions workflow for deployment to production ## Dev/Test Certificate Creation diff --git a/server/keycloak.ts b/server/keycloak.ts index 2ed8e1668..5d731b141 100644 --- a/server/keycloak.ts +++ b/server/keycloak.ts @@ -10,7 +10,7 @@ import { FEATURE_KEYCLOAK_MIGRATION } from './services/feature-flags'; import { sanitize } from './utils'; const MAX_RETRY = 5; -const options = ['bceid', 'bceid_business', 'idir']; +const options = ['bceid', 'bceid_business', 'idir', 'moh_idp']; const regionMap = { region_fraser: 'Fraser', diff --git a/server/scripts/export-user-ha.ts b/server/scripts/export-user-ha.ts new file mode 100644 index 000000000..c063b49f1 --- /dev/null +++ b/server/scripts/export-user-ha.ts @@ -0,0 +1,143 @@ +/* eslint-disable no-console, no-restricted-syntax, no-await-in-loop */ +import { PromisePool } from '@supercharge/promise-pool'; +import fs from 'fs'; +import path from 'path'; +import { AllRoles } from '../constants'; +import keycloak from '../keycloak'; +import { dbClient } from '../db'; +import { getUserSites } from '../services/user'; +import { EmployerSite } from '../services/employers'; + +type UserWithHAArray = { + id: string; + username: string; + firstName: string; + lastName: string; + email: string; + roles: string[]; + sites?: string[]; + HA?: string[]; +}; + +const HA_VALUES = ['Fraser', 'Interior', 'Vancouver Island', 'Northern', 'Vancouver Coastal']; + +// Function to cache BCeID user roles and sites +export const exportUserHAs = async (includeAll: boolean) => { + if (includeAll) { + console.info('Extracting all users'); + } + + const users = []; + + const keycloakUsers: { id: string }[] = await keycloak.getUsers(AllRoles); + await PromisePool.for(keycloakUsers) + .withConcurrency(10) + .process(async (user) => { + users.push({ ...user, roles: await keycloak.getUserRoles(user.id) }); + }); + + if (users.length > 0) { + await dbClient.connect(); + + // Check if the database client is initialized correctly + if (!('db' in dbClient) || !dbClient.db) throw new Error('Database failed to initialize!'); + + // For all users with BCeID roles, get their sites and health authorities + const usersWithSites: UserWithHAArray[] = await Promise.all( + users.map(async (user) => { + // Check if the user has a business BCeID or if we are including all users + if (!user.username.includes('@bceid_business') && !includeAll) { + return null; + } + // Get all sites for the user + const userSites = await getUserSites(user.id); + // Get the HAs for those sites + const HAs: string[] = userSites.map((site: EmployerSite) => site.healthAuthority); + if (user.roles.includes('ministry_of_health')) { + // If the user is a Ministry of Health user, they have access to all HAs + return { + ...user, + sites: userSites.map((site: EmployerSite) => site.siteId), + HA: HA_VALUES, + }; + } + return { + ...user, + sites: userSites.map((site: EmployerSite) => site.siteId), + HA: [...new Set(HAs)], + }; + }) + ).then((results) => results.filter((user) => user !== null)); + + console.info(`Found ${usersWithSites.length} users with sites`); + + const finalUserList: { + id: string; + username: string; + firstName: string; + lastName: string; + email: string; + roles: string[]; + sites?: string[]; + HA?: string; + }[] = []; + + usersWithSites.forEach((user) => { + if (user.HA.length === 0) { + finalUserList.push({ + id: user.id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + roles: user.roles, + sites: user.sites, + HA: '', + }); + return; + } + // Generate a row for each health authority the user has access to + // This allows users with access to multiple HAs to have multiple rows in the CSV + user.HA.forEach((ha) => { + finalUserList.push({ + id: user.id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + roles: user.roles, + sites: user.sites, + HA: ha, + }); + }); + }); + + // Create output directory if it doesn't exist + if (!fs.existsSync(path.join(__dirname, 'output'))) { + fs.mkdirSync(path.join(__dirname, 'output'), { recursive: true }); + } + // Write to CSV file + // Format: username,firstName,lastName,email,HA + const csv = finalUserList + .map((user) => `${user.username},${user.firstName},${user.lastName},${user.email},${user.HA}`) + .join('\n'); + await fs.promises + .writeFile(path.join(__dirname, 'output/business-bceid-users.csv'), csv) + .then(() => { + console.info( + 'Business BCeID users cached successfully to ./server/scripts/output/business-bceid-users.csv' + ); + }) + .catch((err) => { + console.error('Error writing to ./server/scripts/output/business-bceid-users.csv:', err); + }); + } +}; + +(async function main() { + if (require.main === module) { + const includeAll = process.argv.includes('--all'); + await keycloak.buildInternalIdMap(); + await exportUserHAs(includeAll); + } +})(); diff --git a/server/services/reporting/milestones-report.ts b/server/services/reporting/milestones-report.ts index 5bf09baf1..bde3e027f 100644 --- a/server/services/reporting/milestones-report.ts +++ b/server/services/reporting/milestones-report.ts @@ -1,5 +1,5 @@ import { dbClient, collections } from '../../db'; -import { mapRosEntries } from './ros-entries'; +import { mapRosEntries, applyDistinct } from './ros-entries'; import logger from '../../logger'; export const getMohRosMilestonesReport = async () => { @@ -22,16 +22,6 @@ export const getMohRosMilestonesReport = async () => { id: 'site_id', }, }, - participantStatusJoin: { - type: 'LEFT OUTER', - decomposeTo: 'object', - relation: collections.PARTICIPANTS_STATUS, - on: { - participant_id: 'participant_id', - current: true, - 'data.site': 'siteJoin.body.siteId', - }, - }, }) .find( {}, @@ -48,42 +38,38 @@ export const getMohRosMilestonesReport = async () => { if (!entries || entries.length === 0) { return []; } - // Get the ROS completion status for each participant - const participantIds = entries.map((entry) => entry.participant_id); - - const rosCompletionStatuses = await dbClient.db[collections.PARTICIPANTS_STATUS].find({ - participant_id: participantIds, - status: 'archived', - 'data.type': 'rosComplete', - 'data.confirmed': 'true', - current: true, - }); - - // Create a map of participant IDs to their ROS completion status - const rosCompletionMap = new Map(); - rosCompletionStatuses.forEach((status) => { - rosCompletionMap.set(status.participant_id, { - completed: true, - remainingInSectorOrRoleOrAnother: - status.data?.remainingInSectorOrRoleOrAnother || 'Unknown', - }); - }); - - // Enhance the entries with the ROS completion information - const enhancedEntries = entries.map((entry) => { - const rosCompletion = rosCompletionMap.get(entry.participant_id) || { - completed: false, - remainingInSectorOrRoleOrAnother: 'Unknown', - }; - - return { - ...entry, - rosCompleted: rosCompletion.completed ? 'TRUE' : 'FALSE', - remainingInSectorOrRoleOrAnother: rosCompletion.remainingInSectorOrRoleOrAnother, - }; - }); - - return mapRosEntries(enhancedEntries); + + // Process each entry to get ROS completion status individually + const enhancedEntries = await Promise.all( + entries.map(async (entry) => { + // Get ROS completion status for this specific participant + const rosCompletionStatus = await dbClient.db[collections.PARTICIPANTS_STATUS].findOne( + { + participant_id: entry.participant_id, + status: 'archived', + 'data.type': 'rosComplete', + 'data.confirmed': 'true', + current: true, + }, + { + order: [{ field: 'id', direction: 'desc' }], + } + ); + + const rosCompleted = rosCompletionStatus ? 'TRUE' : 'FALSE'; + const remainingInSectorOrRoleOrAnother = + rosCompletionStatus?.data?.remainingInSectorOrRoleOrAnother || 'Unknown'; + + return { + ...entry, + rosCompleted, + remainingInSectorOrRoleOrAnother, + }; + }) + ); + + const mappedEntries = mapRosEntries(enhancedEntries); + return applyDistinct(mappedEntries); } catch (error) { logger.error(`Error generating MoH ROS milestones report: ${error.message}`); throw error; @@ -109,16 +95,6 @@ export const getHARosMilestonesReport = async (region: string) => { id: 'site_id', }, }, - participantStatusJoin: { - type: 'LEFT OUTER', - decomposeTo: 'object', - relation: collections.PARTICIPANTS_STATUS, - on: { - participant_id: 'participant_id', - current: true, - 'data.site': 'siteJoin.body.siteId', - }, - }, }) .find( { @@ -139,6 +115,9 @@ export const getHARosMilestonesReport = async (region: string) => { return []; } + // Get the IDs of entries we already have to avoid duplicates + const existingEntryIds = new Set(sameSiteRosEntries.map((entry) => entry.id)); + // HAs need only see the participants in their health region + participants who changed their health region and now assigned to a site withing HAs view // select participants outside HAs region for changed sites const editedEntries = await dbClient.db[collections.ROS_STATUS] @@ -158,16 +137,6 @@ export const getHARosMilestonesReport = async (region: string) => { id: 'site_id', }, }, - participantStatusJoin: { - type: 'LEFT OUTER', - decomposeTo: 'object', - relation: collections.PARTICIPANTS_STATUS, - on: { - participant_id: 'participant_id', - current: true, - 'data.site': 'siteJoin.body.siteId', - }, - }, }) .find({ participant_id: sameSiteRosEntries.map((entry) => entry.participant_id), @@ -175,51 +144,46 @@ export const getHARosMilestonesReport = async (region: string) => { 'data.user <>': 'NULL', }); - // see if we need to display this information for HA based on what participants are included - // if participants are already visible to HA - include information about their previous sites + // Filter out duplicates from editedEntries + const uniqueEditedEntries = editedEntries.filter((entry) => !existingEntryIds.has(entry.id)); + let rosEntries = sameSiteRosEntries; - if (editedEntries.length > 0) { - rosEntries = rosEntries.concat(editedEntries); + if (uniqueEditedEntries.length > 0) { + rosEntries = rosEntries.concat(uniqueEditedEntries); rosEntries.sort((a, b) => a.participant_id - b.participant_id); } - // Get all participant IDs from the combined entries - const participantIds = rosEntries.map((entry) => entry.participant_id); - - // Get ROS completion statuses for these participants - const rosCompletionStatuses = await dbClient.db[collections.PARTICIPANTS_STATUS].find({ - participant_id: participantIds, - status: 'archived', - 'data.type': 'rosComplete', - 'data.confirmed': 'true', - current: true, - }); - - // Create a map of participant IDs to their ROS completion status - const rosCompletionMap = new Map(); - rosCompletionStatuses.forEach((status) => { - rosCompletionMap.set(status.participant_id, { - completed: true, - remainingInSectorOrRoleOrAnother: - status.data?.remainingInSectorOrRoleOrAnother || 'Unknown', - }); - }); - - // Enhance the entries with the ROS completion information - const enhancedEntries = rosEntries.map((entry) => { - const rosCompletion = rosCompletionMap.get(entry.participant_id) || { - completed: false, - remainingInSectorOrRoleOrAnother: 'Unknown', - }; - - return { - ...entry, - rosCompleted: rosCompletion.completed ? 'TRUE' : 'FALSE', - remainingInSectorOrRoleOrAnother: rosCompletion.remainingInSectorOrRoleOrAnother, - }; - }); - - return mapRosEntries(enhancedEntries); + // Process each entry to get ROS completion status individually + const enhancedEntries = await Promise.all( + rosEntries.map(async (entry) => { + // Get ROS completion status for this specific participant + const rosCompletionStatus = await dbClient.db[collections.PARTICIPANTS_STATUS].findOne( + { + participant_id: entry.participant_id, + status: 'archived', + 'data.type': 'rosComplete', + 'data.confirmed': 'true', + current: true, + }, + { + order: [{ field: 'id', direction: 'desc' }], // Get the most recent one + } + ); + + const rosCompleted = rosCompletionStatus ? 'TRUE' : 'FALSE'; + const remainingInSectorOrRoleOrAnother = + rosCompletionStatus?.data?.remainingInSectorOrRoleOrAnother || 'Unknown'; + + return { + ...entry, + rosCompleted, + remainingInSectorOrRoleOrAnother, + }; + }) + ); + + const mappedEntries = mapRosEntries(enhancedEntries); + return applyDistinct(mappedEntries); } catch (error) { logger.error( `Error generating HA ROS milestones report for region ${region}: ${error.message}` diff --git a/server/services/reporting/ros-entries.ts b/server/services/reporting/ros-entries.ts index de86204da..96a3ab8c0 100644 --- a/server/services/reporting/ros-entries.ts +++ b/server/services/reporting/ros-entries.ts @@ -18,21 +18,14 @@ interface RosEntry { healthAuthority: string; }; }; - participantStatusJoin: { - status: string; - current: boolean; - data: { - type: string; - confirmed: boolean; - remainingInSectorOrRoleOrAnother: string; - }; - }; data: { date: Date | string; startDate: Date | string; positionType?: string; employmentType?: string; }; + rosCompleted: string; + remainingInSectorOrRoleOrAnother: string; } export const mapRosEntries = (rosEntries: RosEntry[]) => @@ -48,11 +41,37 @@ export const mapRosEntries = (rosEntries: RosEntry[]) => positionType: entry.data?.positionType || 'Unknown', healthRegion: entry.siteJoin?.body?.healthAuthority, employmentType: entry.data?.employmentType || 'Unknown', - rosCompleted: - entry.participantStatusJoin?.status === 'archived' && - entry.participantStatusJoin?.data?.type === 'rosComplete' && - entry.participantStatusJoin?.current && - entry.participantStatusJoin?.data?.confirmed, - remainingInSectorOrRoleOrAnother: - entry.participantStatusJoin?.data?.remainingInSectorOrRoleOrAnother ?? 'Unknown', + rosCompleted: entry.rosCompleted, + remainingInSectorOrRoleOrAnother: entry.remainingInSectorOrRoleOrAnother, })); + +//A helper for the DISTINCT logic +export const applyDistinct = (entries) => { + const uniqueEntries = []; + const seenRecords = new Set(); + + entries.forEach((entry) => { + const distinctKey = JSON.stringify({ + participantId: entry.participantId, + firstName: entry.firstName, + lastName: entry.lastName, + program: entry.program, + startDate: entry.startDate, + endDate: entry.endDate, + siteStartDate: entry.siteStartDate, + positionType: entry.positionType, + employmentType: entry.employmentType, + site: entry.site, + healthRegion: entry.healthRegion, + rosCompleted: entry.rosCompleted, + remainingInSectorOrRoleOrAnother: entry.remainingInSectorOrRoleOrAnother, + }); + + if (!seenRecords.has(distinctKey)) { + seenRecords.add(distinctKey); + uniqueEntries.push(entry); + } + }); + + return uniqueEntries; +};