diff --git a/Makefile b/Makefile index 6bfb3a9f1a..295f193f9e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ install: npm install --prefix backend run: - (cd backend && npm run dev-start) & \ + (cd backend && npm run new-start) & \ (cd frontend && npm run build:watch) test-fe: diff --git a/backend/package.json b/backend/package.json index 294c3c0e4c..72fba337a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,8 +2,7 @@ "name": "ng-sfc-v2", "version": "0.0.1", "scripts": { - "dev-start": "npm run api:server", - "new-start": "npm run db:migrate:pipeline && npm run api:server", + "new-start": "npm run api:server", "start": "npm run db:migrate:pipeline && node --max-old-space-size=4096 server.js", "build": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --no-progress", "build:clean": "rimraf dist", diff --git a/backend/server/models/establishment.js b/backend/server/models/establishment.js index 252b271e29..2ce64e0108 100644 --- a/backend/server/models/establishment.js +++ b/backend/server/models/establishment.js @@ -2354,25 +2354,9 @@ module.exports = function (sequelize, DataTypes) { }); }; - const nhsBsaAttributes = [ - 'id', - 'nmdsId', - 'NameValue', - 'address1', - 'locationId', - 'town', - 'postcode', - 'isParent', - 'dataOwner', - 'NumberOfStaffValue', - 'parentId', - ]; - - Establishment.getNhsBsaApiDataByWorkplaceId = async function (where) { - return await this.findOne({ - nhsBsaAttributes, + const nhsBsaApiQuery = (where) => { + return { as: 'establishment', - where: { archived: false, ...where, @@ -2384,29 +2368,29 @@ module.exports = function (sequelize, DataTypes) { attributes: ['name', 'category'], required: true, }, + { + model: sequelize.models.worker, + as: 'workers', + attributes: ['WdfEligible'], + where: { + archived: false, + }, + required: false, + }, ], - }); + }; }; - Establishment.getNhsBsaApiDataForSubs = async function (establishmentId) { - return await this.findAll({ - nhsBsaAttributes, - as: 'establishment', + Establishment.getNhsBsaApiDataByWorkplaceId = async function (workplaceId) { + return await this.findOne(nhsBsaApiQuery({ nmdsId: workplaceId })); + }; - where: { - archived: false, - parentId: establishmentId, - }, + Establishment.getNhsBsaApiDataForParent = async function (workplaceId) { + return await this.findOne(nhsBsaApiQuery({ id: workplaceId })); + }; - include: [ - { - model: sequelize.models.services, - as: 'mainService', - attributes: ['name', 'category'], - required: true, - }, - ], - }); + Establishment.getNhsBsaApiDataForSubs = async function (parentId) { + return await this.findAll(nhsBsaApiQuery({ parentId })); }; return Establishment; diff --git a/backend/server/routes/nhsBsaApi/workplaceData.js b/backend/server/routes/nhsBsaApi/workplaceData.js index af960b8b56..ea68ddfc97 100644 --- a/backend/server/routes/nhsBsaApi/workplaceData.js +++ b/backend/server/routes/nhsBsaApi/workplaceData.js @@ -9,34 +9,30 @@ const WdfCalculator = require('../../models/classes/wdfCalculator').WdfCalculato const nhsBsaApi = async (req, res) => { const workplaceId = req.params.workplaceId; - const where = { - nmdsId: workplaceId, - }; - try { - const workplaceDetail = await models.establishment.getNhsBsaApiDataByWorkplaceId(where); - if (!workplaceDetail) return res.status(404).json({ error: 'Can not find this Id.' }); + const workplace = await models.establishment.getNhsBsaApiDataByWorkplaceId(workplaceId); + if (!workplace) return res.status(404).json({ error: 'Cannot find this Id.' }); - const isParent = workplaceDetail.isParent; - const establishmentId = workplaceDetail.id; - const parentId = workplaceDetail.parentId; + const isParent = workplace.isParent; + const establishmentId = workplace.id; + const parentId = workplace.parentId; let workplaceData = null; if (isParent) { workplaceData = { - isParent: workplaceDetail.isParent, - workplaceDetails: await workplaceObject(workplaceDetail), + isParent, + workplaceDetails: await workplaceObject(workplace), subsidiaries: await subsidiariesList(establishmentId), }; } else if (parentId) { workplaceData = { - workplaceDetails: await workplaceObject(workplaceDetail), + workplaceDetails: await workplaceObject(workplace), parentWorkplace: await parentWorkplace(parentId), }; } else { workplaceData = { - workplaceDetails: await workplaceObject(workplaceDetail), + workplaceDetails: await workplaceObject(workplace), }; } @@ -51,8 +47,6 @@ const nhsBsaApi = async (req, res) => { }; const workplaceObject = async (workplace) => { - const wdfEligible = await wdfData(workplace.id, WdfCalculator.effectiveDate); - return { workplaceId: workplace.nmdsId, workplaceName: workplace.NameValue, @@ -66,12 +60,20 @@ const workplaceObject = async (workplace) => { numberOfWorkplaceStaff: workplace.NumberOfStaffValue, serviceName: workplace.mainService.name, serviceCategory: workplace.mainService.category, - ...wdfEligible, + eligibilityPercentage: calculatePercentageOfWorkersEligible(workplace.workers), + eligibilityDate: workplace.overallWdfEligibility, + isEligible: workplaceIsEligible(workplace), }; }; -const subsidiariesList = async (establishmentId) => { - const subs = await models.establishment.getNhsBsaApiDataForSubs(establishmentId); +const workplaceIsEligible = (workplace) => { + return workplace.overallWdfEligibility && workplace.overallWdfEligibility.getTime() > WdfCalculator.effectiveDate + ? true + : false; +}; + +const subsidiariesList = async (parentId) => { + const subs = await models.establishment.getNhsBsaApiDataForSubs(parentId); const subsidiaries = await Promise.all( subs.map(async (workplace) => { @@ -82,41 +84,21 @@ const subsidiariesList = async (establishmentId) => { }; const parentWorkplace = async (parentId) => { - const where = { - id: parentId, - }; - const parentWorkplace = await models.establishment.getNhsBsaApiDataByWorkplaceId(where); + const parentWorkplace = await models.establishment.getNhsBsaApiDataForParent(parentId); return await workplaceObject(parentWorkplace); }; -const wdfData = async (workplaceId, effectiveFrom) => { - const reportData = await models.sequelize.query( - `SELECT * FROM cqc.wdfsummaryreport(:givenEffectiveDate) WHERE "EstablishmentID" = '${workplaceId}'`, - { - replacements: { - givenEffectiveDate: effectiveFrom, - }, - type: models.sequelize.QueryTypes.SELECT, - }, - ); +const calculatePercentageOfWorkersEligible = (workers) => { + const numberOfWorkers = workers?.length; - const wdfMeeting = reportData.find((workplace) => workplace.EstablishmentID === workplaceId); - if (wdfMeeting) { - const percentageEligibleWorkers = - wdfMeeting.WorkerCount > 0 ? Math.floor((wdfMeeting.WorkerCompletedCount / wdfMeeting.WorkerCount) * 100) : 0; - - return { - eligibilityPercentage: percentageEligibleWorkers, - eligibilityDate: wdfMeeting.OverallWdfEligibility, - isEligible: - wdfMeeting.OverallWdfEligibility && wdfMeeting.OverallWdfEligibility.getTime() > effectiveFrom ? true : false, - }; - } + if (!numberOfWorkers) return 0; + const numberOfEligibleWorkers = workers.filter((worker) => worker.get('WdfEligible')).length; + + return Math.floor((numberOfEligibleWorkers / numberOfWorkers) * 100); }; router.route('/:workplaceId').get(authLimiter, authorization.isAuthorised, nhsBsaApi); module.exports = router; module.exports.nhsBsaApi = nhsBsaApi; module.exports.subsidiariesList = subsidiariesList; -module.exports.wdfData = wdfData; diff --git a/backend/server/test/unit/routes/nhsBsaApi/workplaceData.spec.js b/backend/server/test/unit/routes/nhsBsaApi/workplaceData.spec.js index b210298582..e7ea85413b 100644 --- a/backend/server/test/unit/routes/nhsBsaApi/workplaceData.spec.js +++ b/backend/server/test/unit/routes/nhsBsaApi/workplaceData.spec.js @@ -2,38 +2,41 @@ const expect = require('chai').expect; const sinon = require('sinon'); const httpMocks = require('node-mocks-http'); -const { nhsBsaApi, subsidiariesList, wdfData } = require('../../../../routes/nhsBsaApi/workplaceData'); +const { nhsBsaApi } = require('../../../../routes/nhsBsaApi/workplaceData'); const models = require('../../../../models'); +const WdfCalculator = require('../../../../models/classes/wdfCalculator').WdfCalculator; describe('server/routes/nhsBsaApi/workplaceData.js', () => { const workplaceId = 'J1001845'; - let result; - result = { - id: 949, - nmdsId: 'J1001845', - NameValue: 'SKILLS FOR CARE', - address1: 'WEST GATE', - locationId: null, - town: 'LEEDS', - postcode: 'LS1 2RP', - isParent: false, - dataOwner: 'Workplace', - NumberOfStaffValue: 2, - parentId: null, - mainService: { - name: 'Domiciliary care services', - category: 'Adult domiciliary', + const request = { + method: 'GET', + url: `/api/v1/workplaces/${workplaceId}`, + + params: { + workplaceId: workplaceId, }, }; + let result; beforeEach(() => { - const request = { - method: 'GET', - url: `/api/v1/workplaces/${workplaceId}`, - - params: { - workplaceId: workplaceId, + result = { + id: 949, + nmdsId: 'J1001845', + NameValue: 'SKILLS FOR CARE', + address1: 'WEST GATE', + locationId: null, + town: 'LEEDS', + postcode: 'LS1 2RP', + isParent: false, + dataOwner: 'Workplace', + NumberOfStaffValue: 2, + parentId: null, + overallWdfEligibility: new Date('2021-05-13T09:27:34.471Z'), + mainService: { + name: 'Domiciliary care services', + category: 'Adult domiciliary', }, + workers: [], }; req = httpMocks.createRequest(request); @@ -45,13 +48,19 @@ describe('server/routes/nhsBsaApi/workplaceData.js', () => { }); describe('nhsBsaApi', () => { - it('should return 200 when successfully retrieving a workplace data ', async () => { + it('should return 200 when workplace data successfully retrieved', async () => { sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); await nhsBsaApi(req, res); - const response = res._getJSONData(); expect(res.statusCode).to.equal(200); + }); + + it('should return data from database call in expected format', async () => { + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); + + await nhsBsaApi(req, res); + const response = res._getJSONData(); expect(response.workplaceData).to.deep.equal({ workplaceDetails: { @@ -74,14 +83,17 @@ describe('server/routes/nhsBsaApi/workplaceData.js', () => { }); }); - it('should return successfully a list of subsidiaries', async () => { - sinon.stub(models.establishment, 'getNhsBsaApiDataForSubs').returns([result]); + it('should return subsidiaries as an array formatted in the same way as a standalone when is parent', async () => { + result.isParent = true; - const establishmentId = result.id; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); + sinon.stub(models.establishment, 'getNhsBsaApiDataForSubs').returns([result]); - const subs = await subsidiariesList(establishmentId); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - expect(subs).to.deep.equal([ + expect(response.workplaceData.isParent).to.equal(true); + expect(response.workplaceData.subsidiaries).to.deep.equal([ { workplaceId: 'J1001845', workplaceName: 'SKILLS FOR CARE', @@ -92,18 +104,42 @@ describe('server/routes/nhsBsaApi/workplaceData.js', () => { serviceName: 'Domiciliary care services', serviceCategory: 'Adult domiciliary', eligibilityPercentage: 0, - eligibilityDate: new Date('2021-05-13T09:27:34.471Z'), + eligibilityDate: '2021-05-13T09:27:34.471Z', isEligible: false, }, ]); }); + it('should return parent data formatted in the same way as a standalone when workplace has parent ID', async () => { + result.parentId = 'J1001845'; + + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); + sinon.stub(models.establishment, 'getNhsBsaApiDataForParent').returns(result); + + await nhsBsaApi(req, res); + const response = res._getJSONData(); + + expect(response.workplaceData.parentWorkplace).to.deep.equal({ + workplaceId: 'J1001845', + workplaceName: 'SKILLS FOR CARE', + dataOwner: 'Workplace', + workplaceAddress: { firstLine: 'WEST GATE', town: 'LEEDS', postCode: 'LS1 2RP' }, + locationId: null, + numberOfWorkplaceStaff: 2, + serviceName: 'Domiciliary care services', + serviceCategory: 'Adult domiciliary', + eligibilityPercentage: 0, + eligibilityDate: '2021-05-13T09:27:34.471Z', + isEligible: false, + }); + }); + it('should return 404 when workplace is not found', async () => { sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(null); await nhsBsaApi(req, res); expect(res.statusCode).to.deep.equal(404); - expect(res._getJSONData()).to.deep.equal({ error: 'Can not find this Id.' }); + expect(res._getJSONData()).to.deep.equal({ error: 'Cannot find this Id.' }); }); it('should return 500 when an error is thrown', async () => { @@ -112,84 +148,106 @@ describe('server/routes/nhsBsaApi/workplaceData.js', () => { expect(res.statusCode).to.deep.equal(500); }); - }); - describe('wdfData', () => { - const establishmentId = 'A1234567'; + describe('eligibilityPercentage', () => { + it('should set eligibilityPercentage to 0 when workers returned as null', async () => { + result.workers = null; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - const returnData = (WorkerCompletedCount = 1, WorkerCount = 4, OverallWdfEligibility = null) => { - return [ - { - EstablishmentID: establishmentId, - WorkerCompletedCount, - WorkerCount, - OverallWdfEligibility, - }, - ]; - }; + await nhsBsaApi(req, res); + const response = res._getJSONData(); - it('should return isEligible as false when no date returned for OverallWdfEligibility', async () => { - sinon.stub(models.sequelize, 'query').returns(returnData(1, 4, null)); - const effectiveTime = new Date('2022-05-13T09:27:34.471Z').getTime(); - const wdf = await wdfData(establishmentId, effectiveTime); + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(0); + }); - expect(wdf.isEligible).to.equal(false); - expect(wdf.eligibilityDate).to.equal(null); - }); + it('should set eligibilityPercentage to 0 when workers returned as empty array', async () => { + result.workers = []; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - it('should return isEligible as false when date returned for OverallWdfEligibility is before effectiveTime', async () => { - const OverallWdfEligibility = new Date('2022-03-13T09:27:34.471Z'); - const effectiveTime = new Date('2022-05-13T09:27:34.471Z').getTime(); - sinon.stub(models.sequelize, 'query').returns(returnData(1, 4, OverallWdfEligibility)); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - const wdf = await wdfData(establishmentId, effectiveTime); + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(0); + }); - expect(wdf.isEligible).to.equal(false); - expect(wdf.eligibilityDate).to.equal(OverallWdfEligibility); - }); + it('should set eligibilityPercentage to 0 when several workers returned but none have WdfEligible as true', async () => { + result.workers = [{ get: () => false }, { get: () => false }, { get: () => false }]; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - it('should return isEligible as true when date returned for OverallWdfEligibility is after effectiveTime', async () => { - const OverallWdfEligibility = new Date('2022-07-13T09:27:34.471Z'); - const effectiveTime = new Date('2022-06-13T09:27:34.471Z').getTime(); - sinon.stub(models.sequelize, 'query').returns(returnData(4, 4, OverallWdfEligibility)); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - const wdf = await wdfData(establishmentId, effectiveTime); + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(0); + }); - expect(wdf.isEligible).to.equal(true); - expect(wdf.eligibilityDate).to.equal(OverallWdfEligibility); - }); + it('should set eligibilityPercentage to 100 when all workers returned have WdfEligible as true', async () => { + result.workers = [{ get: () => true }, { get: () => true }, { get: () => true }]; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - describe('eligibilityPercentage', () => { - it('should set percentage to 25 when 1 out of 4 workers completed', async () => { - sinon.stub(models.sequelize, 'query').returns(returnData(1, 4)); + await nhsBsaApi(req, res); + const response = res._getJSONData(); + + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(100); + }); + + it('should set eligibilityPercentage to 25 when 1 out of 4 workers have WdfEligible as true', async () => { + result.workers = [{ get: () => true }, { get: () => false }, { get: () => false }, { get: () => false }]; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - const wdf = await wdfData(establishmentId); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - expect(wdf.eligibilityPercentage).to.equal(25); + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(25); }); - it('should set percentage to 50 when 5 out of 10 workers completed', async () => { - sinon.stub(models.sequelize, 'query').returns(returnData(5, 10)); + it('should set percentage to 66 when 2 out of 3 workers have WdfEligible as true', async () => { + result.workers = [{ get: () => true }, { get: () => true }, { get: () => false }]; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - const wdf = await wdfData(establishmentId); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - expect(wdf.eligibilityPercentage).to.equal(50); + expect(response.workplaceData.workplaceDetails.eligibilityPercentage).to.equal(66); }); + }); - it('should set percentage to 0 when worker count is 0', async () => { - sinon.stub(models.sequelize, 'query').returns(returnData(0, 0)); + describe('isEligible', () => { + it('should return isEligible as false when no date returned for OverallWdfEligibility', async () => { + result.overallWdfEligibility = null; + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - const wdf = await wdfData(establishmentId); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - expect(wdf.eligibilityPercentage).to.equal(0); + expect(response.workplaceData.workplaceDetails.isEligible).to.equal(false); + expect(response.workplaceData.workplaceDetails.eligibilityDate).to.equal(null); }); - it('should set percentage to 100 when worker count is 9 and completed is 9', async () => { - sinon.stub(models.sequelize, 'query').returns(returnData(9, 9)); + it('should return isEligible as false when date returned for OverallWdfEligibility is before effectiveTime', async () => { + const overallWdfEligibility = '2022-03-13T09:27:34.471Z'; + result.overallWdfEligibility = new Date(overallWdfEligibility); + WdfCalculator.effectiveDate = new Date('2022-05-13T09:27:34.471Z').getTime(); + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); + + await nhsBsaApi(req, res); + const response = res._getJSONData(); + + expect(response.workplaceData.workplaceDetails.isEligible).to.equal(false); + expect(response.workplaceData.workplaceDetails.eligibilityDate).to.equal(overallWdfEligibility); + }); + + it('should return isEligible as true when date returned for overallWdfEligibility is after effectiveTime', async () => { + const overallWdfEligibility = '2022-07-13T09:27:34.471Z'; + result.overallWdfEligibility = new Date(overallWdfEligibility); + + WdfCalculator.effectiveDate = new Date('2022-06-13T09:27:34.471Z').getTime(); + sinon.stub(models.establishment, 'getNhsBsaApiDataByWorkplaceId').returns(result); - const wdf = await wdfData(establishmentId); + await nhsBsaApi(req, res); + const response = res._getJSONData(); - expect(wdf.eligibilityPercentage).to.equal(100); + expect(response.workplaceData.workplaceDetails.isEligible).to.equal(true); + expect(response.workplaceData.workplaceDetails.eligibilityDate).to.equal(overallWdfEligibility); }); }); }); diff --git a/buildspec/deploy/run-migrations-benchmark.yml b/buildspec/deploy/run-migrations-benchmark.yml new file mode 100644 index 0000000000..9f6fb5df01 --- /dev/null +++ b/buildspec/deploy/run-migrations-benchmark.yml @@ -0,0 +1,19 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 18 + commands: + - echo Installing dependencies... + - cd backend && npm ci + build: + commands: + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role --role-arn arn:aws:iam::702856547275:role/CodebuildCrossAccountAccessServiceRole --role-session-name MySessionName --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" --output text)) + - export DB_HOST=$(aws ssm get-parameter --name $DB_HOST_PATH --with-decryption --query Parameter.Value --output text) + - export DB_USER=$(aws ssm get-parameter --name $DB_USER_PATH --with-decryption --query Parameter.Value --output text) + - export DB_PASS=$(aws ssm get-parameter --name $DB_PASS_PATH --with-decryption --query Parameter.Value --output text) + - export DB_NAME=$(aws ssm get-parameter --name $DB_NAME_PATH --with-decryption --query Parameter.Value --output text) + - export NODE_ENV=$(aws ssm get-parameter --name $NODE_ENV_PATH --with-decryption --query Parameter.Value --output text) + - echo Running migrations... + - npm run db:migrate:pipeline diff --git a/buildspec/deploy/run-migrations-preprod.yml b/buildspec/deploy/run-migrations-preprod.yml new file mode 100644 index 0000000000..dacb27486a --- /dev/null +++ b/buildspec/deploy/run-migrations-preprod.yml @@ -0,0 +1,19 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 18 + commands: + - echo Installing dependencies... + - cd backend && npm ci + build: + commands: + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role --role-arn arn:aws:iam::114055388985:role/CodebuildCrossAccountAccessServiceRole --role-session-name MySessionName --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" --output text)) + - export DB_HOST=$(aws ssm get-parameter --name $DB_HOST_PATH --with-decryption --query Parameter.Value --output text) + - export DB_USER=$(aws ssm get-parameter --name $DB_USER_PATH --with-decryption --query Parameter.Value --output text) + - export DB_PASS=$(aws ssm get-parameter --name $DB_PASS_PATH --with-decryption --query Parameter.Value --output text) + - export DB_NAME=$(aws ssm get-parameter --name $DB_NAME_PATH --with-decryption --query Parameter.Value --output text) + - export NODE_ENV=$(aws ssm get-parameter --name $NODE_ENV_PATH --with-decryption --query Parameter.Value --output text) + - echo Running migrations... + - npm run db:migrate:pipeline diff --git a/buildspec/deploy/run-migrations-prod.yml b/buildspec/deploy/run-migrations-prod.yml new file mode 100644 index 0000000000..79eaa84d5e --- /dev/null +++ b/buildspec/deploy/run-migrations-prod.yml @@ -0,0 +1,19 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 18 + commands: + - echo Installing dependencies... + - cd backend && npm ci + build: + commands: + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role --role-arn arn:aws:iam::008366934221:role/CodebuildCrossAccountAccessServiceRole --role-session-name MySessionName --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" --output text)) + - export DB_HOST=$(aws ssm get-parameter --name $DB_HOST_PATH --with-decryption --query Parameter.Value --output text) + - export DB_USER=$(aws ssm get-parameter --name $DB_USER_PATH --with-decryption --query Parameter.Value --output text) + - export DB_PASS=$(aws ssm get-parameter --name $DB_PASS_PATH --with-decryption --query Parameter.Value --output text) + - export DB_NAME=$(aws ssm get-parameter --name $DB_NAME_PATH --with-decryption --query Parameter.Value --output text) + - export NODE_ENV=$(aws ssm get-parameter --name $NODE_ENV_PATH --with-decryption --query Parameter.Value --output text) + - echo Running migrations... + - npm run db:migrate:pipeline diff --git a/buildspec/deploy/run-migrations-staging.yml b/buildspec/deploy/run-migrations-staging.yml new file mode 100644 index 0000000000..3aeedcaca6 --- /dev/null +++ b/buildspec/deploy/run-migrations-staging.yml @@ -0,0 +1,19 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 18 + commands: + - echo Installing dependencies... + - cd backend && npm ci + build: + commands: + - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role --role-arn arn:aws:iam::101248264786:role/CodebuildCrossAccountAccessServiceRole --role-session-name MySessionName --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]" --output text)) + - export DB_HOST=$(aws ssm get-parameter --name $DB_HOST_PATH --with-decryption --query Parameter.Value --output text) + - export DB_USER=$(aws ssm get-parameter --name $DB_USER_PATH --with-decryption --query Parameter.Value --output text) + - export DB_PASS=$(aws ssm get-parameter --name $DB_PASS_PATH --with-decryption --query Parameter.Value --output text) + - export DB_NAME=$(aws ssm get-parameter --name $DB_NAME_PATH --with-decryption --query Parameter.Value --output text) + - export NODE_ENV=$(aws ssm get-parameter --name $NODE_ENV_PATH --with-decryption --query Parameter.Value --output text) + - echo Running migrations... + - npm run db:migrate:pipeline \ No newline at end of file diff --git a/frontend/src/app/core/components/header/header.component.html b/frontend/src/app/core/components/header/header.component.html index e7b0444e97..e88fc5f72b 100644 --- a/frontend/src/app/core/components/header/header.component.html +++ b/frontend/src/app/core/components/header/header.component.html @@ -8,7 +8,7 @@ diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.spec.ts index 6f59094436..6ed560f1c8 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.spec.ts @@ -90,6 +90,7 @@ describe('SelectStaffComponent', () => { trainingService, 'clearUpdatingSelectedStaffForMultipleTraining', ).and.callThrough(); + const clearSelectedTrainingCategorySpy = spyOn(trainingService, 'clearSelectedTrainingCategory').and.callThrough(); return { component, @@ -110,6 +111,7 @@ describe('SelectStaffComponent', () => { searchSpy, workers, clearUpdatingSelectedStaffForMultipleTrainingSpy, + clearSelectedTrainingCategorySpy, }; } @@ -599,4 +601,12 @@ describe('SelectStaffComponent', () => { expect(spy.calls.mostRecent().args[0]).toEqual(['../']); }); }); + + it('should call trainingService if there are no selected workers when landing on the page', async () => { + const { component, clearSelectedTrainingCategorySpy } = await setup(); + + component.ngOnInit(); + + expect(clearSelectedTrainingCategorySpy).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.ts index 5b1798267a..18cafa379c 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-staff/select-staff.component.ts @@ -72,6 +72,13 @@ export class SelectStaffComponent implements OnInit, AfterViewInit { private prefill(): void { this.selectedWorkers = this.trainingService.selectedStaff.map((worker) => worker.uid); this.updateSelectAllLinks(); + this.clearSelectedTrainingCategoryOnPageEntry(); + } + + private clearSelectedTrainingCategoryOnPageEntry() { + if (this.selectedWorkers.length === 0) { + this.trainingService.clearSelectedTrainingCategory(); + } } public getPageOfWorkers(): void { diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts index fb1ade6a1c..10d86daaa3 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts @@ -19,9 +19,10 @@ import { AddMultipleTrainingModule } from '../add-multiple-training.module'; import { establishmentBuilder } from '@core/test-utils/MockEstablishmentService'; import { Establishment } from '@core/model/establishment.model'; import { trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; +import sinon from 'sinon'; describe('SelectTrainingCategoryMultipleComponent', () => { - async function setup(prefill = false, accessedFromSummary = false) { + async function setup(prefill = false, accessedFromSummary = false, qsParamGetMock = sinon.stub()) { const establishment = establishmentBuilder() as Establishment; const { fixture, getByText, getAllByText, getByTestId } = await render(SelectTrainingCategoryMultipleComponent, { imports: [HttpClientTestingModule, SharedModule, RouterModule, RouterTestingModule, AddMultipleTrainingModule], @@ -50,6 +51,9 @@ describe('SelectTrainingCategoryMultipleComponent', () => { parent: { url: [{ path: accessedFromSummary ? 'confirm-training' : 'select-staff' }], }, + queryParamMap: { + get: qsParamGetMock, + }, }, }, }, @@ -133,14 +137,14 @@ describe('SelectTrainingCategoryMultipleComponent', () => { expect(routerSpy).toHaveBeenCalledWith(['/dashboard'], { fragment: 'training-and-qualifications' }); }); - it('should show an accordian with the correct categories in', async () => { + it('should show an accordion with the correct categories in', async () => { const { component, getByTestId } = await setup(true); expect(component.categories).toEqual([ { id: 1, seq: 10, category: 'Activity provision/Well-being', trainingCategoryGroup: 'Care skills and knowledge' }, { id: 2, seq: 20, category: 'Autism', trainingCategoryGroup: 'Specific conditions and disabilities' }, { id: 37, seq: 1, category: 'Other', trainingCategoryGroup: null }, ]); - expect(getByTestId('accordian')).toBeTruthy(); + expect(getByTestId('groupedAccordion')).toBeTruthy(); }); it('should return to the select staff page if there is no selected staff', async () => { @@ -224,6 +228,6 @@ describe('SelectTrainingCategoryMultipleComponent', () => { fixture.detectChanges(); expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select the training category').length).toEqual(1); + expect(getAllByText('Select the training category').length).toEqual(2); }); }); diff --git a/frontend/src/app/features/wdf/wdf-data-change/wdf-routing.module.ts b/frontend/src/app/features/wdf/wdf-data-change/wdf-routing.module.ts index b09e8b0e5d..62ecc4cdf0 100644 --- a/frontend/src/app/features/wdf/wdf-data-change/wdf-routing.module.ts +++ b/frontend/src/app/features/wdf/wdf-data-change/wdf-routing.module.ts @@ -14,8 +14,10 @@ import { CountryOfBirthComponent } from '@features/workers/country-of-birth/coun import { DateOfBirthComponent } from '@features/workers/date-of-birth/date-of-birth.component'; import { DaysOfSicknessComponent } from '@features/workers/days-of-sickness/days-of-sickness.component'; import { DisabilityComponent } from '@features/workers/disability/disability.component'; +import { EmployedFromOutsideUkComponent } from '@features/workers/employed-from-outside-uk/employed-from-outside-uk.component'; import { EthnicityComponent } from '@features/workers/ethnicity/ethnicity.component'; import { GenderComponent } from '@features/workers/gender/gender.component'; +import { HealthAndCareVisaComponent } from '@features/workers/health-and-care-visa/health-and-care-visa.component'; import { HomePostcodeComponent } from '@features/workers/home-postcode/home-postcode.component'; import { MainJobStartDateComponent } from '@features/workers/main-job-start-date/main-job-start-date.component'; import { MentalHealthProfessionalComponent } from '@features/workers/mental-health-professional/mental-health-professional.component'; @@ -161,6 +163,16 @@ const routes: Routes = [ component: RecruitedFromComponent, data: { title: 'Recruited From' }, }, + { + path: 'health-and-care-visa', + component: HealthAndCareVisaComponent, + data: { title: 'Health and Care Visa' }, + }, + { + path: 'inside-or-outside-of-uk', + component: EmployedFromOutsideUkComponent, + data: { title: 'Inside or Outside UK' }, + }, { path: 'adult-social-care-started', component: AdultSocialCareStartedComponent, @@ -346,6 +358,16 @@ const routes: Routes = [ component: RecruitedFromComponent, data: { title: 'Recruited From' }, }, + { + path: 'health-and-care-visa', + component: HealthAndCareVisaComponent, + data: { title: 'Health and Care Visa' }, + }, + { + path: 'inside-or-outside-of-uk', + component: EmployedFromOutsideUkComponent, + data: { title: 'Inside or Outside UK' }, + }, { path: 'adult-social-care-started', component: AdultSocialCareStartedComponent, diff --git a/frontend/src/app/features/workers/main-job-role/main-job-role.component.html b/frontend/src/app/features/workers/main-job-role/main-job-role.component.html index c8f2797ea4..7d48ddd06c 100644 --- a/frontend/src/app/features/workers/main-job-role/main-job-role.component.html +++ b/frontend/src/app/features/workers/main-job-role/main-job-role.component.html @@ -14,7 +14,9 @@

{{ worker ? 'Update' : 'Select' }} their mai [accordions]="jobGroups" textShowHideAll="job roles" [preFilledId]="preFilledId" - data-testid="accordian" + data-testid="groupedAccordion" + [hasError]="submitted && form.invalid" + [errorMessage]="formErrorsMap[0].type[0].message" > diff --git a/frontend/src/app/features/workers/main-job-role/main-job-role.component.spec.ts b/frontend/src/app/features/workers/main-job-role/main-job-role.component.spec.ts index 35a08846fe..084af03457 100644 --- a/frontend/src/app/features/workers/main-job-role/main-job-role.component.spec.ts +++ b/frontend/src/app/features/workers/main-job-role/main-job-role.component.spec.ts @@ -33,71 +33,74 @@ describe('MainJobRoleComponent', () => { } else { path = 'staff-record-summary'; } - const { fixture, getByText, getByTestId, getByLabelText, queryByTestId } = await render(MainJobRoleComponent, { - imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], - declarations: [ProgressBarComponent], - schemas: [NO_ERRORS_SCHEMA], - providers: [ - UntypedFormBuilder, - AlertService, - WindowRef, - { - provide: PermissionsService, - useFactory: MockPermissionsService.factory(), - deps: [HttpClient, Router, UserService], - }, - { - provide: UserService, - useFactory: MockUserService.factory(0, Roles.Admin), - deps: [HttpClient], - }, - { - provide: WorkerService, - useClass: MockWorkerServiceWithUpdateWorker, - }, - { - provide: ActivatedRoute, - useValue: { - parent: { + const { fixture, getByText, getAllByText, getByTestId, getByLabelText, queryByTestId } = await render( + MainJobRoleComponent, + { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], + declarations: [ProgressBarComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + UntypedFormBuilder, + AlertService, + WindowRef, + { + provide: PermissionsService, + useFactory: MockPermissionsService.factory(), + deps: [HttpClient, Router, UserService], + }, + { + provide: UserService, + useFactory: MockUserService.factory(0, Roles.Admin), + deps: [HttpClient], + }, + { + provide: WorkerService, + useClass: MockWorkerServiceWithUpdateWorker, + }, + { + provide: ActivatedRoute, + useValue: { + parent: { + snapshot: { + url: [{ path }], + data: { + establishment: { uid: 'mocked-uid' }, + primaryWorkplace: {}, + }, + }, + }, snapshot: { - url: [{ path }], + params: {}, data: { - establishment: { uid: 'mocked-uid' }, - primaryWorkplace: {}, + jobs: [ + { + id: 4, + jobRoleGroup: 'Professional and related roles', + title: 'Allied health professional (not occupational therapist)', + }, + { + id: 10, + jobRoleGroup: 'Care providing roles', + title: 'Care worker', + }, + { + id: 23, + title: 'Registered nurse', + jobRoleGroup: 'Professional and related roles', + }, + { + id: 27, + title: 'Social worker', + jobRoleGroup: 'Professional and related roles', + }, + ], }, }, }, - snapshot: { - params: {}, - data: { - jobs: [ - { - id: 4, - jobRoleGroup: 'Professional and related roles', - title: 'Allied health professional (not occupational therapist)', - }, - { - id: 10, - jobRoleGroup: 'Care providing roles', - title: 'Care worker', - }, - { - id: 23, - title: 'Registered nurse', - jobRoleGroup: 'Professional and related roles', - }, - { - id: 27, - title: 'Social worker', - jobRoleGroup: 'Professional and related roles', - }, - ], - }, - }, }, - }, - ], - }); + ], + }, + ); const component = fixture.componentInstance; const injector = getTestBed(); @@ -129,6 +132,7 @@ describe('MainJobRoleComponent', () => { fixture, getByTestId, getByText, + getAllByText, getByLabelText, router, routerSpy, @@ -177,7 +181,7 @@ describe('MainJobRoleComponent', () => { it('should show the accordion', async () => { const { getByTestId } = await setup(false, true); - expect(getByTestId('accordian')).toBeTruthy(); + expect(getByTestId('groupedAccordion')).toBeTruthy(); }); it('should show the accordion headings', async () => { @@ -296,13 +300,13 @@ describe('MainJobRoleComponent', () => { }); it('should return an error message if user clicked submit without selecting a job role', async () => { - const { fixture, getByText } = await setup(true, false, true); + const { fixture, getByText, getAllByText } = await setup(true, false, true); userEvent.click(getByText('Save this staff record')); fixture.detectChanges(); expect(getByText('There is a problem')).toBeTruthy(); - expect(getByText('Select the job role')).toBeTruthy(); + expect(getAllByText('Select the job role')).toHaveSize(2); }); }); diff --git a/frontend/src/app/features/workers/select-record-type/select-record-type.component.spec.ts b/frontend/src/app/features/workers/select-record-type/select-record-type.component.spec.ts index 9e540a70f0..7c4c8b2a4e 100644 --- a/frontend/src/app/features/workers/select-record-type/select-record-type.component.spec.ts +++ b/frontend/src/app/features/workers/select-record-type/select-record-type.component.spec.ts @@ -30,6 +30,8 @@ describe('SelectRecordTypeComponent', () => { const injector = getTestBed(); const router = injector.inject(Router) as Router; const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + const trainingService = injector.inject(TrainingService) as TrainingService; + const clearSelectedTrainingCategorySpy = spyOn(trainingService, 'clearSelectedTrainingCategory').and.callThrough(); return { component, @@ -37,6 +39,7 @@ describe('SelectRecordTypeComponent', () => { routerSpy, getByText, getAllByText, + clearSelectedTrainingCategorySpy, }; } @@ -62,7 +65,7 @@ describe('SelectRecordTypeComponent', () => { fixture.detectChanges(); const form = component.form; expect(form.valid).toBeTruthy(); - // expect(form.value).toEqual({ selectRecordType: 'Training course' }); + expect(form.value).toEqual({ selectRecordType: 'Training course' }); }); it('should not prefill the radio button when navigating from training page', async () => { @@ -74,4 +77,12 @@ describe('SelectRecordTypeComponent', () => { expect(form.value).toEqual({ selectRecordType: null }); }); }); + + it('should call trainingService if there is no trainingOrQualificationPreviouslySelected when landing on the page', async () => { + const { component, clearSelectedTrainingCategorySpy } = await setup(); + + component.ngOnInit(); + + expect(clearSelectedTrainingCategorySpy).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/features/workers/select-record-type/select-record-type.component.ts b/frontend/src/app/features/workers/select-record-type/select-record-type.component.ts index 58741bbdda..97bdf25a73 100644 --- a/frontend/src/app/features/workers/select-record-type/select-record-type.component.ts +++ b/frontend/src/app/features/workers/select-record-type/select-record-type.component.ts @@ -57,6 +57,14 @@ export class SelectRecordTypeComponent implements OnInit, AfterViewInit { if (this.trainingOrQualificationPreviouslySelected) { this.prefill(this.getPrefillValue()); } + + this.clearSelectedTrainingCategoryOnPageEntry(); + } + + private clearSelectedTrainingCategoryOnPageEntry() { + if (!this.trainingOrQualificationPreviouslySelected || this.trainingOrQualificationPreviouslySelected === 'null') { + this.trainingService.clearSelectedTrainingCategory(); + } } private setupForm(): void { diff --git a/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.html b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.html index 7d3ee95736..19695185de 100644 --- a/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.html +++ b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.html @@ -1,23 +1,29 @@ -
-
- -
-
- +
+
+
+ +
+ +

+ Error: {{ errorMessage }} +

+
+ +
diff --git a/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.spec.ts b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.spec.ts new file mode 100644 index 0000000000..58c861f6e3 --- /dev/null +++ b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.spec.ts @@ -0,0 +1,69 @@ +import { render } from '@testing-library/angular'; +import { GroupedRadioButtonAccordionComponent } from './grouped-radio-button-accordion.component'; +import { SharedModule } from '@shared/shared.module'; +import { FormsModule } from '@angular/forms'; + +describe('GroupedRadioButtonAccordionComponent', () => { + async function setup(props?) { + const { fixture, getByText, getByTestId } = await render(GroupedRadioButtonAccordionComponent, { + imports: [SharedModule, FormsModule], + providers: [], + componentProperties: { + preFilledId: props?.preFilledId ? props.preFilledId : 10, + formControlName: props?.formControlName ? props.formControlName : 'testAccordions', + textShowHideAll: props?.textShowHideAll ? props.textShowHideAll : 'category', + hasError: props?.hasError ? props.hasError : false, + errorMessage: props?.errorMessage ? props.errorMessage : 'Select the training category', + accordions: props?.accordions + ? props.accordions + : [ + { + title: 'Test Accordion', + descriptionText: 'A Description', + open: false, + index: 0, + items: [ + { + id: 1, + label: 'option 1', + }, + { + id: 2, + label: 'option 2', + }, + ], + }, + ], + }, + }); + + const component = fixture.componentInstance; + + return { component, fixture, getByText }; + } + + it('should render', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should display the toggle text', async () => { + const { getByText } = await setup(); + + const toggleText = getByText('Show all category'); + expect(toggleText).toBeTruthy; + }); + + it('display an error message that we passed in', async () => { + const { getByText } = await setup({ hasError: true, errorMessage: 'Select the job role' }); + + const errorMessage = getByText('Select the job role'); + expect(errorMessage).toBeTruthy(); + }); + + it('open all the accordions when there is an error', async () => { + const { component } = await setup({ hasError: true, errorMessage: 'Select the job role' }); + + expect(component.showAll).toBe(true); + }); +}); diff --git a/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.ts b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.ts index 82a1dca962..6a31c13785 100644 --- a/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.ts +++ b/frontend/src/app/shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component.ts @@ -18,6 +18,8 @@ export class GroupedRadioButtonAccordionComponent implements ControlValueAccesso @Input() preFilledId: number; @Input() formControlName: string; @Input() textShowHideAll?: string; + @Input() hasError: boolean = false; + @Input() errorMessage: string; @Input() set accordions( value: { title: string; @@ -67,6 +69,13 @@ export class GroupedRadioButtonAccordionComponent implements ControlValueAccesso this.toggleAccordionOfPrefilledRadioButton(); } + ngOnChanges(): void { + if (this.hasError) { + this.openAll(); + this.updateToggleAlltext(); + } + } + private openAll(): void { this.showAll = true; this.accordions.forEach((x) => (x.open = true)); diff --git a/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html b/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html index 503d981ce9..672d6de479 100644 --- a/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html +++ b/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html @@ -1,6 +1,6 @@ -
+
@@ -14,27 +14,32 @@

{{ title }}

[accordions]="trainingGroups" textShowHideAll="categories" [preFilledId]="preFilledId" - data-testid="accordian" + data-testid="groupedAccordion" + [hasError]="submitted && error" + [errorMessage]="formErrorsMap[0].type[0].message" >
-
- - +
+
+ + +
+