From 19adc57ca99db8a22a93219e6b21b0b90741a9e3 Mon Sep 17 00:00:00 2001 From: Jonathan Hardy Date: Tue, 8 Oct 2024 11:22:57 +0100 Subject: [PATCH 001/100] merged training certificates and quals accordions --- ...3822-createTableForTrainingCertificates.js | 71 + backend/package-lock.json | 5273 +++++++++++++---- backend/package.json | 3 + backend/server/config/config.js | 24 + backend/server/models/classes/training.js | 173 +- backend/server/models/trainingCertificates.js | 99 + backend/server/models/worker.js | 6 + backend/server/models/workerTraining.js | 6 + .../routes/establishments/training/index.js | 37 +- .../establishments/workerCertificate/s3.js | 79 + .../workerCertificate/trainingCertificate.js | 201 + backend/server/test/unit/mockdata/training.js | 43 + .../test/unit/models/classes/training.spec.js | 226 +- .../establishments/training/index.spec.js | 132 + .../trainingCertificate.spec.js | 368 ++ frontend/src/app/core/model/training.model.ts | 49 + .../core/services/training.service.spec.ts | 204 +- .../src/app/core/services/training.service.ts | 151 +- .../src/app/core/services/worker.service.ts | 11 +- .../app/core/test-utils/MockWorkerService.ts | 7 +- .../add-edit-training.component.spec.ts | 689 ++- .../add-edit-training.component.ts | 142 +- .../training-details.component.spec.ts | 114 +- .../training-details.component.ts | 4 + ...g-and-qualifications-record.component.html | 45 +- ...nd-qualifications-record.component.spec.ts | 321 +- ...ualifications-record.component.spec.ts.OFF | 754 --- ...ing-and-qualifications-record.component.ts | 110 +- .../new-training/new-training.component.html | 103 +- .../new-training/new-training.component.scss | 4 + .../new-training.component.spec.ts | 326 +- .../new-training/new-training.component.ts | 41 +- .../certifications-table.component.html | 72 + .../certifications-table.component.scss | 20 + .../certifications-table.component.spec.ts | 199 + .../certifications-table.component.ts | 32 + .../validation-error-message.component.html | 8 +- .../select-upload-file.component.html | 19 + .../select-upload-file.component.scss | 3 + .../select-upload-file.component.spec.ts | 89 + .../select-upload-file.component.ts | 30 + .../add-edit-training.component.html | 484 +- .../add-edit-training.directive.ts | 17 +- frontend/src/app/shared/shared.module.ts | 6 + .../validators/custom-form-validators.ts | 15 + frontend/src/assets/scss/modules/_utils.scss | 4 + 46 files changed, 8357 insertions(+), 2457 deletions(-) create mode 100644 backend/migrations/20240912113822-createTableForTrainingCertificates.js create mode 100644 backend/server/models/trainingCertificates.js create mode 100644 backend/server/routes/establishments/workerCertificate/s3.js create mode 100644 backend/server/routes/establishments/workerCertificate/trainingCertificate.js create mode 100644 backend/server/test/unit/routes/establishments/training/index.spec.js create mode 100644 backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js delete mode 100644 frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts.OFF create mode 100644 frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss create mode 100644 frontend/src/app/shared/components/certifications-table/certifications-table.component.html create mode 100644 frontend/src/app/shared/components/certifications-table/certifications-table.component.scss create mode 100644 frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts create mode 100644 frontend/src/app/shared/components/certifications-table/certifications-table.component.ts create mode 100644 frontend/src/app/shared/components/select-upload-file/select-upload-file.component.html create mode 100644 frontend/src/app/shared/components/select-upload-file/select-upload-file.component.scss create mode 100644 frontend/src/app/shared/components/select-upload-file/select-upload-file.component.spec.ts create mode 100644 frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts diff --git a/backend/migrations/20240912113822-createTableForTrainingCertificates.js b/backend/migrations/20240912113822-createTableForTrainingCertificates.js new file mode 100644 index 0000000000..a133a36e01 --- /dev/null +++ b/backend/migrations/20240912113822-createTableForTrainingCertificates.js @@ -0,0 +1,71 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.createTable( + 'TrainingCertificates', + { + ID: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + UID: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()'), + allowNull: false, + unique: true, + }, + WorkerTrainingFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'WorkerTraining', + schema: 'cqc', + }, + key: 'ID', + }, + }, + WorkerFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'Worker', + schema: 'cqc', + }, + key: 'ID', + }, + }, + FileName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + UploadDate: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + Key: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + }, + { schema: 'cqc' }, + ); + }, + + async down(queryInterface) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + return queryInterface.dropTable({ + tableName: 'TrainingCertificates', + schema: 'cqc', + }); + }, +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 6466d6ba62..a143aa9ca8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,9 @@ "name": "ng-sfc-v2", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "^3.650.0", + "@aws-sdk/credential-providers": "^3.664.0", + "@aws-sdk/s3-request-presigner": "^3.650.0", "@babel/runtime": "^7.21.0", "@istanbuljs/nyc-config-typescript": "^1.0.1", "@sentry/browser": "^6.13.1", @@ -287,1723 +290,4612 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", "dependencies": { - "color-convert": "^1.9.0" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dependencies": { + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "dependencies": { - "color-name": "1.1.3" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "has-flag": "^3.0.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/core": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", - "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.6", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", - "@babel/types": "^7.23.6", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": ">=16.0.0" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dependencies": { + "tslib": "^2.6.2" } }, - "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dependencies": { + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.0.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.664.0.tgz", + "integrity": "sha512-kOk4hIJy51xta2Tq2bNonHXdOZEZ3b3IdctxSYPtXMxATvhGPxEYm4reiIabDxBxWv+blF5qM54pBQXV/dsfrQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.664.0", + "@aws-sdk/client-sts": "3.664.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/client-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.664.0.tgz", + "integrity": "sha512-E0MObuGylqY2yf47bZZAFK+4+C13c4Cs3HobXgCV3+myoHaxxQHltQuGrapxWOiJJzNmABKEPjBcMnRWnZHXCQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.664.0.tgz", + "integrity": "sha512-VgnAnQwt88oj6OSnIEouvTKN8JI2PzcC3qWQSL87ZtzBSscfrSwbtBNqBxk6nQWwE7AlZuzvT7IN6loz6c7kGA==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/core": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.664.0.tgz", + "integrity": "sha512-QdfMpTpJqtpuFIFfUJEgJ+Rq/dO3I5iaViLKr9Zad4Gfi/GiRWTeXd4IvjcyRntB5GkyCak9RKMkxkECQavPJg==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.664.0.tgz", + "integrity": "sha512-95rE+9Voaco0nmKJrXqfJAxSSkSWqlBy76zomiZrUrv7YuijQtHCW8jte6v6UHAFAaBzgFsY7QqBxs15u9SM7g==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.664.0.tgz", + "integrity": "sha512-svaPwVfWV3g/qjd4cYHTUyBtkdOwcVjC+tSj6EjoMrpZwGUXcCbYe04iU0ARZ6tuH/u3vySbTLOGjSa7g8o9Qw==", "dependencies": { - "@babel/types": "^7.22.5" + "@aws-sdk/types": "3.664.0", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.664.0.tgz", + "integrity": "sha512-ykRLQi9gqY7xlgC33iEWyPMv19JDMpOqQfqb5zaV46NteT60ouBrS3WsCrDiwygF7HznGLpr0lpt17/C6Mq27g==", "dependencies": { - "@babel/types": "^7.22.15" + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.664.0.tgz", + "integrity": "sha512-JrLtx4tEtEzqYAmk+pz8B7QcBCNRN+lZAh3fbQox7q9YQaIELLM3MA6LM5kEp/uHop920MQvdhHOMtR5jjJqWA==", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-ini": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.664.0.tgz", + "integrity": "sha512-sQicIw/qWTsmMw8EUQNJXdrWV5SXaZc2zGdCQsQxhR6wwNO2/rZ5JmzdcwUADmleBVyPYk3KGLhcofF/qXT2Ng==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.664.0.tgz", + "integrity": "sha512-r7m+XkTAvGT9nW4aHqjWOHcoo3EfUsXx6d9JJjWn/gnvdsvhobCJx8p621aR9WeSBUTKJg5+EXGhZF6awRdZGQ==", "dependencies": { - "@babel/types": "^7.22.5" + "@aws-sdk/client-sso": "3.664.0", + "@aws-sdk/token-providers": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.664.0.tgz", + "integrity": "sha512-10ltP1BfSKRJVXd8Yr5oLbo+VSDskWbps0X3szSsxTk0Dju1xvkz7hoIjylWLvtGbvQ+yb2pmsJYKCudW/4DJg==", "dependencies": { - "@babel/types": "^7.22.5" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.664.0.tgz", + "integrity": "sha512-4tCXJ+DZWTq38eLmFgnEmO8X4jfWpgPbWoCyVYpRHCPHq6xbrU65gfwS9jGx25L4YdEce641ChI9TKLryuUgRA==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/middleware-logger": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.664.0.tgz", + "integrity": "sha512-eNykMqQuv7eg9pAcaLro44fscIe1VkFfhm+gYnlxd+PH6xqapRki1E68VHehnIptnVBdqnWfEqLUSLGm9suqhg==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.664.0.tgz", + "integrity": "sha512-jq27WMZhm+dY8BWZ9Ipy3eXtZj0lJzpaKQE3A3tH5AOIlUV/gqrmnJ9CdqVVef4EJsq9Yil4ZzQjKKmPsxveQg==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helpers": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz", - "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.664.0.tgz", + "integrity": "sha512-Kp5UwXwayO6d472nntiwgrxqay2KS9ozXNmKjQfDrUWbEzvgKI+jgKNMia8MMnjSxYoBGpQ1B8NGh8a6KMEJJg==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", - "@babel/types": "^7.23.6" + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.664.0.tgz", + "integrity": "sha512-o/B8dg8K+9714RGYPgMxZgAChPe/MTSMkf/eHXTUFHNik5i1HgVKfac22njV2iictGy/6GhpFsKa1OWNYAkcUg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/token-providers": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.664.0.tgz", + "integrity": "sha512-dBAvXW2/6bAxidvKARFxyCY2uCynYBKRFN00NhS1T5ggxm3sUnuTpWw1DTjl02CVPkacBOocZf10h8pQbHSK8w==", "dependencies": { - "color-convert": "^1.9.0" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.664.0" } }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/types": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.664.0.tgz", + "integrity": "sha512-+GtXktvVgpreM2b+NJL9OqZGsOzHwlCUrO8jgQUvH/yA6Kd8QO2YFhQCp0C9sSzTteZJVqGBu8E0CQurxJHPbw==", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.664.0.tgz", + "integrity": "sha512-KrXoHz6zmAahVHkyWMRT+P6xJaxItgmklxEDrT+npsUB4d5C/lhw16Crcp9TDi828fiZK3GYKRAmmNhvmzvBNg==", "dependencies": { - "color-name": "1.1.3" + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "@smithy/util-endpoints": "^2.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.664.0.tgz", + "integrity": "sha512-c/PV3+f1ss4PpskHbcOxTZ6fntV2oXy/xcDR9nW+kVaz5cM1G702gF0rvGLKPqoBwkj2rWGe6KZvEBeLzynTUQ==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.664.0.tgz", + "integrity": "sha512-l/m6KkgrTw1p/VTJTk0IoP9I2OnpWp3WbBgzxoNeh9cUcxTufIn++sBxKj5hhDql57LKWsckScG/MhFuH0vZZA==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4" + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@aws-sdk/client-s3": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.650.0.tgz", + "integrity": "sha512-6ZfkDu2FMOtYPV1ah5vWMqFKNKEqlBQ3/NOVvLGscU1dR0ybbOwwm4ywWofZmz72uOts5NGqe12kzohb/AsGAA==", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.650.0", + "@aws-sdk/client-sts": "3.650.0", + "@aws-sdk/core": "3.649.0", + "@aws-sdk/credential-provider-node": "3.650.0", + "@aws-sdk/middleware-bucket-endpoint": "3.649.0", + "@aws-sdk/middleware-expect-continue": "3.649.0", + "@aws-sdk/middleware-flexible-checksums": "3.649.0", + "@aws-sdk/middleware-host-header": "3.649.0", + "@aws-sdk/middleware-location-constraint": "3.649.0", + "@aws-sdk/middleware-logger": "3.649.0", + "@aws-sdk/middleware-recursion-detection": "3.649.0", + "@aws-sdk/middleware-sdk-s3": "3.649.0", + "@aws-sdk/middleware-ssec": "3.649.0", + "@aws-sdk/middleware-user-agent": "3.649.0", + "@aws-sdk/region-config-resolver": "3.649.0", + "@aws-sdk/signature-v4-multi-region": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-endpoints": "3.649.0", + "@aws-sdk/util-user-agent-browser": "3.649.0", + "@aws-sdk/util-user-agent-node": "3.649.0", + "@aws-sdk/xml-builder": "3.649.0", + "@smithy/config-resolver": "^3.0.6", + "@smithy/core": "^2.4.1", + "@smithy/eventstream-serde-browser": "^3.0.7", + "@smithy/eventstream-serde-config-resolver": "^3.0.4", + "@smithy/eventstream-serde-node": "^3.0.6", + "@smithy/fetch-http-handler": "^3.2.5", + "@smithy/hash-blob-browser": "^3.1.3", + "@smithy/hash-node": "^3.0.4", + "@smithy/hash-stream-node": "^3.1.3", + "@smithy/invalid-dependency": "^3.0.4", + "@smithy/md5-js": "^3.0.4", + "@smithy/middleware-content-length": "^3.0.6", + "@smithy/middleware-endpoint": "^3.1.1", + "@smithy/middleware-retry": "^3.0.16", + "@smithy/middleware-serde": "^3.0.4", + "@smithy/middleware-stack": "^3.0.4", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/node-http-handler": "^3.2.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/url-parser": "^3.0.4", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.16", + "@smithy/util-defaults-mode-node": "^3.0.16", + "@smithy/util-endpoints": "^2.1.0", + "@smithy/util-middleware": "^3.0.4", + "@smithy/util-retry": "^3.0.4", + "@smithy/util-stream": "^3.1.4", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sts": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.650.0.tgz", + "integrity": "sha512-ISK0ZQYA7O5/WYgslpWy956lUBudGC9d7eL0FFbiL0j50N80Gx3RUv22ezvZgxJWE0W3DqNr4CE19sPYn4Lw8g==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.650.0", + "@aws-sdk/core": "3.649.0", + "@aws-sdk/credential-provider-node": "3.650.0", + "@aws-sdk/middleware-host-header": "3.649.0", + "@aws-sdk/middleware-logger": "3.649.0", + "@aws-sdk/middleware-recursion-detection": "3.649.0", + "@aws-sdk/middleware-user-agent": "3.649.0", + "@aws-sdk/region-config-resolver": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-endpoints": "3.649.0", + "@aws-sdk/util-user-agent-browser": "3.649.0", + "@aws-sdk/util-user-agent-node": "3.649.0", + "@smithy/config-resolver": "^3.0.6", + "@smithy/core": "^2.4.1", + "@smithy/fetch-http-handler": "^3.2.5", + "@smithy/hash-node": "^3.0.4", + "@smithy/invalid-dependency": "^3.0.4", + "@smithy/middleware-content-length": "^3.0.6", + "@smithy/middleware-endpoint": "^3.1.1", + "@smithy/middleware-retry": "^3.0.16", + "@smithy/middleware-serde": "^3.0.4", + "@smithy/middleware-stack": "^3.0.4", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/node-http-handler": "^3.2.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/url-parser": "^3.0.4", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.16", + "@smithy/util-defaults-mode-node": "^3.0.16", + "@smithy/util-endpoints": "^2.1.0", + "@smithy/util-middleware": "^3.0.4", + "@smithy/util-retry": "^3.0.4", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.650.0.tgz", + "integrity": "sha512-YKm14gCMChD/jlCisFlsVqB8HJujR41bl4Fup2crHwNJxhD/9LTnzwMiVVlBqlXr41Sfa6fSxczX2AMP8NM14A==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.649.0", + "@aws-sdk/middleware-host-header": "3.649.0", + "@aws-sdk/middleware-logger": "3.649.0", + "@aws-sdk/middleware-recursion-detection": "3.649.0", + "@aws-sdk/middleware-user-agent": "3.649.0", + "@aws-sdk/region-config-resolver": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-endpoints": "3.649.0", + "@aws-sdk/util-user-agent-browser": "3.649.0", + "@aws-sdk/util-user-agent-node": "3.649.0", + "@smithy/config-resolver": "^3.0.6", + "@smithy/core": "^2.4.1", + "@smithy/fetch-http-handler": "^3.2.5", + "@smithy/hash-node": "^3.0.4", + "@smithy/invalid-dependency": "^3.0.4", + "@smithy/middleware-content-length": "^3.0.6", + "@smithy/middleware-endpoint": "^3.1.1", + "@smithy/middleware-retry": "^3.0.16", + "@smithy/middleware-serde": "^3.0.4", + "@smithy/middleware-stack": "^3.0.4", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/node-http-handler": "^3.2.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/url-parser": "^3.0.4", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.16", + "@smithy/util-defaults-mode-node": "^3.0.16", + "@smithy/util-endpoints": "^2.1.0", + "@smithy/util-middleware": "^3.0.4", + "@smithy/util-retry": "^3.0.4", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.650.0.tgz", + "integrity": "sha512-6J7IS0f8ovhvbIAZaynOYP+jPX8344UlTjwHxjaXHgFvI8axu3+NslKtEEV5oHLhgzDvrKbinsu5lgE2n4Sqng==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.649.0", + "@aws-sdk/credential-provider-node": "3.650.0", + "@aws-sdk/middleware-host-header": "3.649.0", + "@aws-sdk/middleware-logger": "3.649.0", + "@aws-sdk/middleware-recursion-detection": "3.649.0", + "@aws-sdk/middleware-user-agent": "3.649.0", + "@aws-sdk/region-config-resolver": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-endpoints": "3.649.0", + "@aws-sdk/util-user-agent-browser": "3.649.0", + "@aws-sdk/util-user-agent-node": "3.649.0", + "@smithy/config-resolver": "^3.0.6", + "@smithy/core": "^2.4.1", + "@smithy/fetch-http-handler": "^3.2.5", + "@smithy/hash-node": "^3.0.4", + "@smithy/invalid-dependency": "^3.0.4", + "@smithy/middleware-content-length": "^3.0.6", + "@smithy/middleware-endpoint": "^3.1.1", + "@smithy/middleware-retry": "^3.0.16", + "@smithy/middleware-serde": "^3.0.4", + "@smithy/middleware-stack": "^3.0.4", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/node-http-handler": "^3.2.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/url-parser": "^3.0.4", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.16", + "@smithy/util-defaults-mode-node": "^3.0.16", + "@smithy/util-endpoints": "^2.1.0", + "@smithy/util-middleware": "^3.0.4", + "@smithy/util-retry": "^3.0.4", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.650.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.664.0.tgz", + "integrity": "sha512-+kFS+B/U/thLi8yxYgKc7QFsababYrgrIkbVgTvSzudkzk5RIlDu753L/DfXqYOtecbc6WUwlTKA+Ltee3OVXg==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.664.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/client-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.664.0.tgz", + "integrity": "sha512-E0MObuGylqY2yf47bZZAFK+4+C13c4Cs3HobXgCV3+myoHaxxQHltQuGrapxWOiJJzNmABKEPjBcMnRWnZHXCQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.664.0.tgz", + "integrity": "sha512-VgnAnQwt88oj6OSnIEouvTKN8JI2PzcC3qWQSL87ZtzBSscfrSwbtBNqBxk6nQWwE7AlZuzvT7IN6loz6c7kGA==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/core": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.664.0.tgz", + "integrity": "sha512-QdfMpTpJqtpuFIFfUJEgJ+Rq/dO3I5iaViLKr9Zad4Gfi/GiRWTeXd4IvjcyRntB5GkyCak9RKMkxkECQavPJg==", "dependencies": { - "has-flag": "^3.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", - "bin": { - "parser": "bin/babel-parser.js" + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.664.0.tgz", + "integrity": "sha512-95rE+9Voaco0nmKJrXqfJAxSSkSWqlBy76zomiZrUrv7YuijQtHCW8jte6v6UHAFAaBzgFsY7QqBxs15u9SM7g==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", - "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.664.0.tgz", + "integrity": "sha512-svaPwVfWV3g/qjd4cYHTUyBtkdOwcVjC+tSj6EjoMrpZwGUXcCbYe04iU0ARZ6tuH/u3vySbTLOGjSa7g8o9Qw==", "dependencies": { - "regenerator-runtime": "^0.14.0" + "@aws-sdk/types": "3.664.0", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.6.tgz", - "integrity": "sha512-Djs/ZTAnpyj0nyg7p1J6oiE/tZ9G2stqAFlLGZynrW+F3k2w2jGK2mLOBxzYIOcZYA89+c3d3wXKpYLcpwcU6w==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.664.0.tgz", + "integrity": "sha512-ykRLQi9gqY7xlgC33iEWyPMv19JDMpOqQfqb5zaV46NteT60ouBrS3WsCrDiwygF7HznGLpr0lpt17/C6Mq27g==", "dependencies": { - "core-js-pure": "^3.30.2", - "regenerator-runtime": "^0.14.0" + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.664.0.tgz", + "integrity": "sha512-JrLtx4tEtEzqYAmk+pz8B7QcBCNRN+lZAh3fbQox7q9YQaIELLM3MA6LM5kEp/uHop920MQvdhHOMtR5jjJqWA==", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-ini": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/traverse": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", - "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.664.0.tgz", + "integrity": "sha512-sQicIw/qWTsmMw8EUQNJXdrWV5SXaZc2zGdCQsQxhR6wwNO2/rZ5JmzdcwUADmleBVyPYk3KGLhcofF/qXT2Ng==", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.664.0.tgz", + "integrity": "sha512-r7m+XkTAvGT9nW4aHqjWOHcoo3EfUsXx6d9JJjWn/gnvdsvhobCJx8p621aR9WeSBUTKJg5+EXGhZF6awRdZGQ==", + "dependencies": { + "@aws-sdk/client-sso": "3.664.0", + "@aws-sdk/token-providers": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.664.0.tgz", + "integrity": "sha512-10ltP1BfSKRJVXd8Yr5oLbo+VSDskWbps0X3szSsxTk0Dju1xvkz7hoIjylWLvtGbvQ+yb2pmsJYKCudW/4DJg==", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.664.0.tgz", + "integrity": "sha512-4tCXJ+DZWTq38eLmFgnEmO8X4jfWpgPbWoCyVYpRHCPHq6xbrU65gfwS9jGx25L4YdEce641ChI9TKLryuUgRA==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=0.1.90" + "node": ">=16.0.0" } }, - "node_modules/@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-logger": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.664.0.tgz", + "integrity": "sha512-eNykMqQuv7eg9pAcaLro44fscIe1VkFfhm+gYnlxd+PH6xqapRki1E68VHehnIptnVBdqnWfEqLUSLGm9suqhg==", "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "http-signature": "~1.3.6", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "~6.10.3", - "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 6" + "node": ">=16.0.0" } }, - "node_modules/@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.664.0.tgz", + "integrity": "sha512-jq27WMZhm+dY8BWZ9Ipy3eXtZj0lJzpaKQE3A3tH5AOIlUV/gqrmnJ9CdqVVef4EJsq9Yil4ZzQjKKmPsxveQg==", "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.664.0.tgz", + "integrity": "sha512-Kp5UwXwayO6d472nntiwgrxqay2KS9ozXNmKjQfDrUWbEzvgKI+jgKNMia8MMnjSxYoBGpQ1B8NGh8a6KMEJJg==", "dependencies": { - "ms": "^2.1.1" + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.664.0.tgz", + "integrity": "sha512-o/B8dg8K+9714RGYPgMxZgAChPe/MTSMkf/eHXTUFHNik5i1HgVKfac22njV2iictGy/6GhpFsKa1OWNYAkcUg==", "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/token-providers": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.664.0.tgz", + "integrity": "sha512-dBAvXW2/6bAxidvKARFxyCY2uCynYBKRFN00NhS1T5ggxm3sUnuTpWw1DTjl02CVPkacBOocZf10h8pQbHSK8w==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=10.0.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.664.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.664.0.tgz", + "integrity": "sha512-+GtXktvVgpreM2b+NJL9OqZGsOzHwlCUrO8jgQUvH/yA6Kd8QO2YFhQCp0C9sSzTteZJVqGBu8E0CQurxJHPbw==", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, - "node_modules/@fast-csv/format": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", - "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.664.0.tgz", + "integrity": "sha512-KrXoHz6zmAahVHkyWMRT+P6xJaxItgmklxEDrT+npsUB4d5C/lhw16Crcp9TDi828fiZK3GYKRAmmNhvmzvBNg==", "dependencies": { - "@types/node": "^14.0.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isboolean": "^3.0.3", - "lodash.isequal": "^4.5.0", - "lodash.isfunction": "^3.0.9", - "lodash.isnil": "^4.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "@smithy/util-endpoints": "^2.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@fast-csv/format/node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" - }, - "node_modules/@fast-csv/parse": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", - "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.664.0.tgz", + "integrity": "sha512-c/PV3+f1ss4PpskHbcOxTZ6fntV2oXy/xcDR9nW+kVaz5cM1G702gF0rvGLKPqoBwkj2rWGe6KZvEBeLzynTUQ==", "dependencies": { - "@types/node": "^14.0.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.groupby": "^4.6.0", - "lodash.isfunction": "^3.0.9", - "lodash.isnil": "^4.0.0", - "lodash.isundefined": "^3.0.1", - "lodash.uniq": "^4.5.0" + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" } }, - "node_modules/@fast-csv/parse/node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.664.0.tgz", + "integrity": "sha512-l/m6KkgrTw1p/VTJTk0IoP9I2OnpWp3WbBgzxoNeh9cUcxTufIn++sBxKj5hhDql57LKWsckScG/MhFuH0vZZA==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", - "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==", - "dev": true, + "node_modules/@aws-sdk/core": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.649.0.tgz", + "integrity": "sha512-dheG/X2y25RHE7K+TlS32kcy7TgDg1OpWV44BQRoE0OBPAWmFR1D1qjjTZ7WWrdqRPKzcnDj1qED8ncyncOX8g==", "dependencies": { - "@formatjs/intl-localematcher": "0.5.4", - "tslib": "^2.4.0" + "@smithy/core": "^2.4.1", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/property-provider": "^3.1.4", + "@smithy/protocol-http": "^4.1.1", + "@smithy/signature-v4": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/util-middleware": "^3.0.4", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", - "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.664.0.tgz", + "integrity": "sha512-wOWir00Ve38kSnkoP8CS8Vq4UqRSCSrHm7Nym1iAL0Hmf4hOQRcWXBKP08/dHpk4nt4+LqVd+dT8V2LhN7RCog==", "dependencies": { - "tslib": "^2.4.0" + "@aws-sdk/client-cognito-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.7.8", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz", - "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-cognito-identity/node_modules/@aws-sdk/types": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.664.0.tgz", + "integrity": "sha512-+GtXktvVgpreM2b+NJL9OqZGsOzHwlCUrO8jgQUvH/yA6Kd8QO2YFhQCp0C9sSzTteZJVqGBu8E0CQurxJHPbw==", "dependencies": { - "@formatjs/ecma402-abstract": "2.0.0", - "@formatjs/icu-skeleton-parser": "1.8.2", - "tslib": "^2.4.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz", - "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.649.0.tgz", + "integrity": "sha512-tViwzM1dauksA3fdRjsg0T8mcHklDa8EfveyiQKK6pUJopkqV6FQx+X5QNda0t/LrdEVlFZvwHNdXqOEfc83TA==", "dependencies": { - "@formatjs/ecma402-abstract": "2.0.0", - "tslib": "^2.4.0" + "@aws-sdk/types": "3.649.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", - "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.649.0.tgz", + "integrity": "sha512-ODAJ+AJJq6ozbns6ejGbicpsQ0dyMOpnGlg0J9J0jITQ05DKQZ581hdB8APDOZ9N8FstShP6dLZflSj8jb5fNA==", "dependencies": { - "tslib": "^2.4.0" + "@aws-sdk/types": "3.649.0", + "@smithy/fetch-http-handler": "^3.2.5", + "@smithy/node-http-handler": "^3.2.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/util-stream": "^3.1.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.650.0.tgz", + "integrity": "sha512-x2M9buZxIsKuUbuDgkGHhAKYBpn0/rYdKlwuFuOhXyyAcnhvPj0lgNF2KE4ld/GF1mKr7FF/uV3G9lM6PFaYmA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.649.0", + "@aws-sdk/credential-provider-http": "3.649.0", + "@aws-sdk/credential-provider-process": "3.649.0", + "@aws-sdk/credential-provider-sso": "3.650.0", + "@aws-sdk/credential-provider-web-identity": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@smithy/credential-provider-imds": "^3.2.1", + "@smithy/property-provider": "^3.1.4", + "@smithy/shared-ini-file-loader": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.650.0" + } }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.650.0.tgz", + "integrity": "sha512-uBra5YjzS/gWSekAogfqJfY6c+oKQkkou7Cjc4d/cpMNvQtF1IBdekJ7NaE1RfsDEz3uH1+Myd07YWZAJo/2Qw==", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@aws-sdk/credential-provider-env": "3.649.0", + "@aws-sdk/credential-provider-http": "3.649.0", + "@aws-sdk/credential-provider-ini": "3.650.0", + "@aws-sdk/credential-provider-process": "3.649.0", + "@aws-sdk/credential-provider-sso": "3.650.0", + "@aws-sdk/credential-provider-web-identity": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@smithy/credential-provider-imds": "^3.2.1", + "@smithy/property-provider": "^3.1.4", + "@smithy/shared-ini-file-loader": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.649.0.tgz", + "integrity": "sha512-6VYPQpEVpU+6DDS/gLoI40ppuNM5RPIEprK30qZZxnhTr5wyrGOeJ7J7wbbwPOZ5dKwta290BiJDU2ipV8Y9BQ==", "dependencies": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" + "@aws-sdk/types": "3.649.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/shared-ini-file-loader": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=10.10.0" + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.650.0.tgz", + "integrity": "sha512-069nkhcwximbvyGiAC6Fr2G+yrG/p1S3NQ5BZ2cMzB1hgUKo6TvgFK7nriYI4ljMQ+UWxqPwIdTqiUmn2iJmhg==", + "dependencies": { + "@aws-sdk/client-sso": "3.650.0", + "@aws-sdk/token-providers": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/shared-ini-file-loader": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.649.0.tgz", + "integrity": "sha512-XVk3WsDa0g3kQFPmnCH/LaCtGY/0R2NDv7gscYZSXiBZcG/fixasglTprgWSp8zcA0t7tEIGu9suyjz8ZwhymQ==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.649.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.664.0.tgz", + "integrity": "sha512-9xxEyvZVsXvf0Dpm7eVYIrLiqOiNSWY8mAk594HldL/GYDokUzokA6NmZyQtCY2rYPSInB/4TCZ1tH4IeXRKeQ==", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.664.0", + "@aws-sdk/client-sso": "3.664.0", + "@aws-sdk/client-sts": "3.664.0", + "@aws-sdk/credential-provider-cognito-identity": "3.664.0", + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-ini": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/client-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.664.0.tgz", + "integrity": "sha512-E0MObuGylqY2yf47bZZAFK+4+C13c4Cs3HobXgCV3+myoHaxxQHltQuGrapxWOiJJzNmABKEPjBcMnRWnZHXCQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.664.0.tgz", + "integrity": "sha512-VgnAnQwt88oj6OSnIEouvTKN8JI2PzcC3qWQSL87ZtzBSscfrSwbtBNqBxk6nQWwE7AlZuzvT7IN6loz6c7kGA==", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.664.0", + "@aws-sdk/credential-provider-node": "3.664.0", + "@aws-sdk/middleware-host-header": "3.664.0", + "@aws-sdk/middleware-logger": "3.664.0", + "@aws-sdk/middleware-recursion-detection": "3.664.0", + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/region-config-resolver": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@aws-sdk/util-user-agent-browser": "3.664.0", + "@aws-sdk/util-user-agent-node": "3.664.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.7", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.22", + "@smithy/util-defaults-mode-node": "^3.0.22", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" + } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/core": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.664.0.tgz", + "integrity": "sha512-QdfMpTpJqtpuFIFfUJEgJ+Rq/dO3I5iaViLKr9Zad4Gfi/GiRWTeXd4IvjcyRntB5GkyCak9RKMkxkECQavPJg==", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.664.0.tgz", + "integrity": "sha512-95rE+9Voaco0nmKJrXqfJAxSSkSWqlBy76zomiZrUrv7YuijQtHCW8jte6v6UHAFAaBzgFsY7QqBxs15u9SM7g==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.664.0.tgz", + "integrity": "sha512-svaPwVfWV3g/qjd4cYHTUyBtkdOwcVjC+tSj6EjoMrpZwGUXcCbYe04iU0ARZ6tuH/u3vySbTLOGjSa7g8o9Qw==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.664.0.tgz", + "integrity": "sha512-ykRLQi9gqY7xlgC33iEWyPMv19JDMpOqQfqb5zaV46NteT60ouBrS3WsCrDiwygF7HznGLpr0lpt17/C6Mq27g==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.664.0.tgz", + "integrity": "sha512-JrLtx4tEtEzqYAmk+pz8B7QcBCNRN+lZAh3fbQox7q9YQaIELLM3MA6LM5kEp/uHop920MQvdhHOMtR5jjJqWA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.664.0", + "@aws-sdk/credential-provider-http": "3.664.0", + "@aws-sdk/credential-provider-ini": "3.664.0", + "@aws-sdk/credential-provider-process": "3.664.0", + "@aws-sdk/credential-provider-sso": "3.664.0", + "@aws-sdk/credential-provider-web-identity": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.664.0.tgz", + "integrity": "sha512-sQicIw/qWTsmMw8EUQNJXdrWV5SXaZc2zGdCQsQxhR6wwNO2/rZ5JmzdcwUADmleBVyPYk3KGLhcofF/qXT2Ng==", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.664.0.tgz", + "integrity": "sha512-r7m+XkTAvGT9nW4aHqjWOHcoo3EfUsXx6d9JJjWn/gnvdsvhobCJx8p621aR9WeSBUTKJg5+EXGhZF6awRdZGQ==", + "dependencies": { + "@aws-sdk/client-sso": "3.664.0", + "@aws-sdk/token-providers": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.664.0.tgz", + "integrity": "sha512-10ltP1BfSKRJVXd8Yr5oLbo+VSDskWbps0X3szSsxTk0Dju1xvkz7hoIjylWLvtGbvQ+yb2pmsJYKCudW/4DJg==", "dependencies": { - "ansi-regex": "^6.0.1" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "peerDependencies": { + "@aws-sdk/client-sts": "^3.664.0" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.664.0.tgz", + "integrity": "sha512-4tCXJ+DZWTq38eLmFgnEmO8X4jfWpgPbWoCyVYpRHCPHq6xbrU65gfwS9jGx25L4YdEce641ChI9TKLryuUgRA==", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/middleware-logger": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.664.0.tgz", + "integrity": "sha512-eNykMqQuv7eg9pAcaLro44fscIe1VkFfhm+gYnlxd+PH6xqapRki1E68VHehnIptnVBdqnWfEqLUSLGm9suqhg==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.664.0.tgz", + "integrity": "sha512-jq27WMZhm+dY8BWZ9Ipy3eXtZj0lJzpaKQE3A3tH5AOIlUV/gqrmnJ9CdqVVef4EJsq9Yil4ZzQjKKmPsxveQg==", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.664.0.tgz", + "integrity": "sha512-Kp5UwXwayO6d472nntiwgrxqay2KS9ozXNmKjQfDrUWbEzvgKI+jgKNMia8MMnjSxYoBGpQ1B8NGh8a6KMEJJg==", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@aws-sdk/types": "3.664.0", + "@aws-sdk/util-endpoints": "3.664.0", + "@smithy/core": "^2.4.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.664.0.tgz", + "integrity": "sha512-o/B8dg8K+9714RGYPgMxZgAChPe/MTSMkf/eHXTUFHNik5i1HgVKfac22njV2iictGy/6GhpFsKa1OWNYAkcUg==", "dependencies": { - "p-locate": "^4.1.0" + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/token-providers": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.664.0.tgz", + "integrity": "sha512-dBAvXW2/6bAxidvKARFxyCY2uCynYBKRFN00NhS1T5ggxm3sUnuTpWw1DTjl02CVPkacBOocZf10h8pQbHSK8w==", "dependencies": { - "p-try": "^2.0.0" + "@aws-sdk/types": "3.664.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.664.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/types": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.664.0.tgz", + "integrity": "sha512-+GtXktvVgpreM2b+NJL9OqZGsOzHwlCUrO8jgQUvH/yA6Kd8QO2YFhQCp0C9sSzTteZJVqGBu8E0CQurxJHPbw==", "dependencies": { - "p-limit": "^2.2.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/util-endpoints": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.664.0.tgz", + "integrity": "sha512-KrXoHz6zmAahVHkyWMRT+P6xJaxItgmklxEDrT+npsUB4d5C/lhw16Crcp9TDi828fiZK3GYKRAmmNhvmzvBNg==", + "dependencies": { + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "@smithy/util-endpoints": "^2.1.3", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/nyc-config-typescript": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", - "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.664.0.tgz", + "integrity": "sha512-c/PV3+f1ss4PpskHbcOxTZ6fntV2oXy/xcDR9nW+kVaz5cM1G702gF0rvGLKPqoBwkj2rWGe6KZvEBeLzynTUQ==", "dependencies": { - "@istanbuljs/schema": "^0.1.2" + "@aws-sdk/types": "3.664.0", + "@smithy/types": "^3.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/credential-providers/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.664.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.664.0.tgz", + "integrity": "sha512-l/m6KkgrTw1p/VTJTk0IoP9I2OnpWp3WbBgzxoNeh9cUcxTufIn++sBxKj5hhDql57LKWsckScG/MhFuH0vZZA==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.664.0", + "@aws-sdk/types": "3.664.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" }, "peerDependencies": { - "nyc": ">=15" + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.649.0.tgz", + "integrity": "sha512-ZdDICtUU4YZkrVllTUOH1Fj/F3WShLhkfNKJE3HJ/yj6pS8JS9P2lWzHiHkHiidjrHSxc6NuBo6vuZ+182XLbw==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "@smithy/util-config-provider": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@jackfranklin/test-data-bot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@jackfranklin/test-data-bot/-/test-data-bot-1.4.0.tgz", - "integrity": "sha512-wVb1pZAVDJeYqr0e4/m8seqW7VFUFs4cQFPCRHTkkl7GRtWX8xr0gL5k0Z4YS3lRlx2bs04ica675l9Eco5Bww==", - "dev": true, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.649.0.tgz", + "integrity": "sha512-pW2id/mWNd+L0/hZKp5yL3J+8rTwsamu9E69Hc5pM3qTF4K4DTZZ+A0sQbY6duIvZvc8IbQHbSMulBOLyWNP3A==", "dependencies": { - "@types/faker": "5.5.9", - "@types/lodash": "4.14.178", - "faker": "5.5.3", - "lodash": "4.17.21" + "@aws-sdk/types": "3.649.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=10.13" + "node": ">=16.0.0" } }, - "node_modules/@jackfranklin/test-data-bot/node_modules/@types/lodash": { - "version": "4.14.178", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", - "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==", - "dev": true + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.649.0.tgz", + "integrity": "sha512-8mzMBEA+Tk6rbrS8iqnXX119C6z+Id84cuzvUc6dAiYcbnOVbus8M4XKKsAFzGGXHCRc2gMwYhKdnoVz2ijaFA==", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-sdk/types": "3.649.0", + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@jest/types": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", - "dev": true, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.649.0.tgz", + "integrity": "sha512-PjAe2FocbicHVgNNwdSZ05upxIO7AgTPFtQLpnIAmoyzMcgv/zNB5fBn3uAnQSAeEPPCD+4SYVEUD1hw1ZBvEg==", "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0" + "@aws-sdk/types": "3.649.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 10.14.2" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.649.0.tgz", + "integrity": "sha512-O9AXhaFUQx34UTnp/cKCcaWW/IVk4mntlWfFjsIxvRatamKaY33b5fOiakGG+J1t0QFK0niDBSvOYUR1fdlHzw==", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@aws-sdk/types": "3.649.0", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.649.0.tgz", + "integrity": "sha512-qdqRx6q7lYC6KL/NT9x3ShTL0TBuxdkCczGzHzY3AnOoYUjnCDH7Vlq867O6MAvb4EnGNECFzIgtkZkQ4FhY5w==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.649.0.tgz", + "integrity": "sha512-IPnO4wlmaLRf6IYmJW2i8gJ2+UPXX0hDRv1it7Qf8DpBW+lGyF2rnoN7NrFX0WIxdGOlJF1RcOr/HjXb2QeXfQ==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.649.0.tgz", + "integrity": "sha512-3H8735xTAD7IxNdreT6qv2YRk4CGOGfz8ufZo5pROJYZ4N5rfcdDMvb8szMSLvQHegqS4v1DqO9nrOPgc0I2Qg==", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@aws-sdk/core": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/core": "^2.4.1", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/protocol-http": "^4.1.1", + "@smithy/signature-v4": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.4", + "@smithy/util-stream": "^3.1.4", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@lhci/cli/-/cli-0.14.0.tgz", - "integrity": "sha512-TxOH9pFBnmmN7Jmo2Aimxx5UhE8veqXpHfFJDMWsCVxkwh7mGxcAWchGl84mK139SZbbRmerqZ72c+h2nG9/QQ==", - "dev": true, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.649.0.tgz", + "integrity": "sha512-r/WBIpX+Kcx+AV5vJ+LbdDOuibk7spBqcFK2LytQjOZKPksZNRAM99khbFe9vr9S1+uDmCLVjAVkIfQ5seJrOw==", "dependencies": { - "@lhci/utils": "0.14.0", - "chrome-launcher": "^0.13.4", - "compression": "^1.7.4", - "debug": "^4.3.1", - "express": "^4.17.1", - "inquirer": "^6.3.1", - "isomorphic-fetch": "^3.0.0", - "lighthouse": "12.1.0", - "lighthouse-logger": "1.2.0", - "open": "^7.1.0", - "proxy-agent": "^6.4.0", - "tmp": "^0.1.0", - "uuid": "^8.3.1", - "yargs": "^15.4.1", - "yargs-parser": "^13.1.2" + "@aws-sdk/types": "3.649.0", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, - "bin": { - "lhci": "src/cli.js" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.649.0.tgz", + "integrity": "sha512-q6sO10dnCXoxe9thobMJxekhJumzd1j6dxcE1+qJdYKHJr6yYgWbogJqrLCpWd30w0lEvnuAHK8lN2kWLdJxJw==", "dependencies": { - "debug": "^4.3.4" + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-endpoints": "3.649.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.649.0.tgz", + "integrity": "sha512-xURBvdQXvRvca5Du8IlC5FyCj3pkw8Z75+373J3Wb+vyg8GjD14HfKk1Je1HCCQDyIE9VB/scYDcm9ri0ppePw==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/types": "^3.4.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.4", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.650.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.650.0.tgz", + "integrity": "sha512-/tAzAOYjN8oTX7dYG/koNc8WI/2htJ6w2cv0Y3smkRyD9nC/s33ZJ6VplC3WBTeGv9lzHazP/EQkXz1IvJKd/Q==", "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" + "@aws-sdk/signature-v4-multi-region": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@aws-sdk/util-format-url": "3.649.0", + "@smithy/middleware-endpoint": "^3.1.1", + "@smithy/protocol-http": "^4.1.1", + "@smithy/smithy-client": "^3.3.0", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.649.0.tgz", + "integrity": "sha512-feJfSHtCarFmTMZSE5k7/A+m4FrdCrmotljc/AmXArWy3wl8XFyxE5tFVW/PiUgbgeoVDN+ZLt3YYtItHfNUWQ==", "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" + "@aws-sdk/middleware-sdk-s3": "3.649.0", + "@aws-sdk/types": "3.649.0", + "@smithy/protocol-http": "^4.1.1", + "@smithy/signature-v4": "^4.1.1", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.649.0.tgz", + "integrity": "sha512-ZBqr+JuXI9RiN+4DSZykMx5gxpL8Dr3exIfFhxMiwAP3DQojwl0ub8ONjMuAjq9OvmX6n+jHZL6fBnNgnNFC8w==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/property-provider": "^3.1.4", + "@smithy/shared-ini-file-loader": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" + "node": ">=16.0.0" }, - "optionalDependencies": { - "source-map": "~0.6.1" + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.649.0" } }, - "node_modules/@lhci/cli/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "node_modules/@aws-sdk/types": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.649.0.tgz", + "integrity": "sha512-PuPw8RysbhJNlaD2d/PzOTf8sbf4Dsn2b7hwyGh7YVG3S75yTpxSAZxrnhKsz9fStgqFmnw/jUfV/G+uQAeTVw==", + "dependencies": { + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4.0" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.568.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.568.0.tgz", + "integrity": "sha512-XUKJWWo+KOB7fbnPP0+g/o5Ulku/X53t7i/h+sPHr5xxYTJJ9CYnbToo95mzxe7xWvkLrsNtJ8L+MnNn9INs2w==", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/get-uri": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", - "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", - "dev": true, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.649.0.tgz", + "integrity": "sha512-bZI1Wc3R/KibdDVWFxX/N4AoJFG4VJ92Dp4WYmOrVD6VPkb8jPz7ZeiYc7YwPl8NoDjYyPneBV0lEoK/V8OKAA==", "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4", - "fs-extra": "^11.2.0" + "@aws-sdk/types": "3.649.0", + "@smithy/types": "^3.4.0", + "@smithy/util-endpoints": "^2.1.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.649.0.tgz", + "integrity": "sha512-I5olOLkXQRJWAaoTSTXcycNBJ26daeEpgxYD6VPpQma9StFVK7a0MbHa1QGkOy9eVTTuf6xb2U1eiCWDWn3TXA==", "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" + "@aws-sdk/types": "3.649.0", + "@smithy/querystring-builder": "^3.0.4", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.568.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", + "integrity": "sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", - "dev": true, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.649.0.tgz", + "integrity": "sha512-IY43r256LhKAvdEVQO/FPdUyVpcZS5EVxh/WHVdNzuN1bNLoUK2rIzuZqVA0EGguvCxoXVmQv9m50GvG7cGktg==", "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" + "@aws-sdk/types": "3.649.0", + "@smithy/types": "^3.4.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.649.0.tgz", + "integrity": "sha512-x5DiLpZDG/AJmCIBnE3Xhpwy35QIo3WqNiOpw6ExVs1NydbM/e90zFPSfhME0FM66D/WorigvluBxxwjxDm/GA==", + "dependencies": { + "@aws-sdk/types": "3.649.0", + "@smithy/node-config-provider": "^3.1.5", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@lhci/cli/node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.649.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.649.0.tgz", + "integrity": "sha512-XVESKkK7m5LdCVzZ3NvAja40BEyCrfPqtaiFAAhJIvW2U1Edyugf2o3XikuQY62crGT6BZagxJFgOiLKvuTiTg==", "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@lhci/cli/node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", - "dev": true, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", "dependencies": { - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" }, "engines": { - "node": ">= 14" + "node": ">=6.9.0" } }, - "node_modules/@lhci/cli/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } }, - "node_modules/@lhci/cli/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/@lhci/utils": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@lhci/utils/-/utils-0.14.0.tgz", - "integrity": "sha512-LyP1RbvYQ9xNl7uLnl5AO8fDRata9MG/KYfVFKFkYenlsVS6QJsNjLzWNEoMIaE4jOPdQQlSp4tO7dtnyDxzbQ==", - "dev": true, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dependencies": { - "debug": "^4.3.1", - "isomorphic-fetch": "^3.0.0", - "js-yaml": "^3.13.1", - "lighthouse": "12.1.0", - "tree-kill": "^1.2.1" + "color-name": "1.1.3" } }, - "node_modules/@ngx-builders/analyze": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@ngx-builders/analyze/-/analyze-3.0.1.tgz", - "integrity": "sha512-Hj1e55hjJhEyTlWTtz7D/ArKmhhj2sm0el3h+K1LYXQwKboQQSmewG7hrGMBZDC7zCitHVmCu5A31Z4PhVkRjg==", - "dev": true, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dependencies": { - "@angular-devkit/architect": "0.1300.0", - "@angular-devkit/core": "13.0.0", - "@angular-devkit/schematics": "^13.0.0" + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", + "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.6", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.6", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz", + "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==", + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.6", + "@babel/types": "^7.23.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", + "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.6.tgz", + "integrity": "sha512-Djs/ZTAnpyj0nyg7p1J6oiE/tZ9G2stqAFlLGZynrW+F3k2w2jGK2mLOBxzYIOcZYA89+c3d3wXKpYLcpwcU6w==", + "dev": true, + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", + "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cypress/request": { + "version": "2.88.12", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", + "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "http-signature": "~1.3.6", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "~6.10.3", + "safe-buffer": "^5.1.2", + "tough-cookie": "^4.1.3", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", + "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==", + "dev": true, + "dependencies": { + "@formatjs/intl-localematcher": "0.5.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", + "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.7.8", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz", + "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==", + "dev": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "@formatjs/icu-skeleton-parser": "1.8.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz", + "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==", + "dev": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/nyc-config-typescript": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", + "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", + "dependencies": { + "@istanbuljs/schema": "^0.1.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "nyc": ">=15" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jackfranklin/test-data-bot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@jackfranklin/test-data-bot/-/test-data-bot-1.4.0.tgz", + "integrity": "sha512-wVb1pZAVDJeYqr0e4/m8seqW7VFUFs4cQFPCRHTkkl7GRtWX8xr0gL5k0Z4YS3lRlx2bs04ica675l9Eco5Bww==", + "dev": true, + "dependencies": { + "@types/faker": "5.5.9", + "@types/lodash": "4.14.178", + "faker": "5.5.3", + "lodash": "4.17.21" + }, + "engines": { + "node": ">=10.13" + } + }, + "node_modules/@jackfranklin/test-data-bot/node_modules/@types/lodash": { + "version": "4.14.178", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", + "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==", + "dev": true + }, + "node_modules/@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lhci/cli": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@lhci/cli/-/cli-0.14.0.tgz", + "integrity": "sha512-TxOH9pFBnmmN7Jmo2Aimxx5UhE8veqXpHfFJDMWsCVxkwh7mGxcAWchGl84mK139SZbbRmerqZ72c+h2nG9/QQ==", + "dev": true, + "dependencies": { + "@lhci/utils": "0.14.0", + "chrome-launcher": "^0.13.4", + "compression": "^1.7.4", + "debug": "^4.3.1", + "express": "^4.17.1", + "inquirer": "^6.3.1", + "isomorphic-fetch": "^3.0.0", + "lighthouse": "12.1.0", + "lighthouse-logger": "1.2.0", + "open": "^7.1.0", + "proxy-agent": "^6.4.0", + "tmp": "^0.1.0", + "uuid": "^8.3.1", + "yargs": "^15.4.1", + "yargs-parser": "^13.1.2" + }, + "bin": { + "lhci": "src/cli.js" + } + }, + "node_modules/@lhci/cli/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/@lhci/cli/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@lhci/cli/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@lhci/cli/node_modules/get-uri": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", + "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", + "dev": true, + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4", + "fs-extra": "^11.2.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@lhci/cli/node_modules/pac-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", + "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", + "dev": true, + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.5", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@lhci/cli/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "node_modules/@lhci/cli/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@lhci/utils": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@lhci/utils/-/utils-0.14.0.tgz", + "integrity": "sha512-LyP1RbvYQ9xNl7uLnl5AO8fDRata9MG/KYfVFKFkYenlsVS6QJsNjLzWNEoMIaE4jOPdQQlSp4tO7dtnyDxzbQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.1", + "isomorphic-fetch": "^3.0.0", + "js-yaml": "^3.13.1", + "lighthouse": "12.1.0", + "tree-kill": "^1.2.1" + } + }, + "node_modules/@ngx-builders/analyze": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ngx-builders/analyze/-/analyze-3.0.1.tgz", + "integrity": "sha512-Hj1e55hjJhEyTlWTtz7D/ArKmhhj2sm0el3h+K1LYXQwKboQQSmewG7hrGMBZDC7zCitHVmCu5A31Z4PhVkRjg==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": "0.1300.0", + "@angular-devkit/core": "13.0.0", + "@angular-devkit/schematics": "^13.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" + }, + "node_modules/@paulirish/trace_engine": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.23.tgz", + "integrity": "sha512-2ym/q7HhC5K+akXkNV6Gip3oaHpbI6TsGjmcAsl7bcJ528MVbacPQeoauLFEeLXH4ulJvsxQwNDIg/kAEhFZxw==", + "dev": true + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.24", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", + "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", + "dev": true + }, + "node_modules/@puppeteer/browsers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", + "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "dev": true, + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/get-uri": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", + "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", + "dev": true, + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4", + "fs-extra": "^11.2.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/pac-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", + "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", + "dev": true, + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.5", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@puppeteer/browsers/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@puppeteer/browsers/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/browser": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.19.7.tgz", + "integrity": "sha512-oDbklp4O3MtAM4mtuwyZLrgO1qDVYIujzNJQzXmi9YzymJCuzMLSRDvhY83NNDCRxf0pds4DShgYeZdbSyKraA==", + "dependencies": { + "@sentry/core": "6.19.7", + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/core": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", + "integrity": "sha512-tOfZ/umqB2AcHPGbIrsFLcvApdTm9ggpi/kQZFkej7kMphjT+SGBiQfYtjyg9jcRW+ilAR4JXC9BGKsdEQ+8Vw==", + "dependencies": { + "@sentry/hub": "6.19.7", + "@sentry/minimal": "6.19.7", + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/core/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/hub": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.19.7.tgz", + "integrity": "sha512-y3OtbYFAqKHCWezF0EGGr5lcyI2KbaXW2Ik7Xp8Mu9TxbSTuwTe4rTntwg8ngPjUQU3SUHzgjqVB8qjiGqFXCA==", + "dependencies": { + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/hub/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/integrations": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.19.7.tgz", + "integrity": "sha512-yNeeFyuygJaV7Mdc5qWuDa13xVj5mVdECaaw2Xs4pfeHaXmRfRzZY17N8ypWFegKWxKBHynyQRMD10W5pBwJvA==", + "dependencies": { + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "localforage": "^1.8.1", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/integrations/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/minimal": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.19.7.tgz", + "integrity": "sha512-wcYmSJOdvk6VAPx8IcmZgN08XTXRwRtB1aOLZm+MVHjIZIhHoBGZJYTVQS/BWjldsamj2cX3YGbGXNunaCfYJQ==", + "dependencies": { + "@sentry/hub": "6.19.7", + "@sentry/types": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/minimal/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/node": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-6.19.7.tgz", + "integrity": "sha512-gtmRC4dAXKODMpHXKfrkfvyBL3cI8y64vEi3fDD046uqYcrWdgoQsffuBbxMAizc6Ez1ia+f0Flue6p15Qaltg==", + "dependencies": { + "@sentry/core": "6.19.7", + "@sentry/hub": "6.19.7", + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "cookie": "^0.4.1", + "https-proxy-agent": "^5.0.0", + "lru_map": "^0.3.3", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/node/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/tracing": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.19.7.tgz", + "integrity": "sha512-ol4TupNnv9Zd+bZei7B6Ygnr9N3Gp1PUrNI761QSlHtPC25xXC5ssSD3GMhBgyQrcvpuRcCFHVNNM97tN5cZiA==", + "dependencies": { + "@sentry/hub": "6.19.7", + "@sentry/minimal": "6.19.7", + "@sentry/types": "6.19.7", + "@sentry/utils": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/tracing/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sentry/types": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.19.7.tgz", + "integrity": "sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/utils": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.19.7.tgz", + "integrity": "sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==", + "dependencies": { + "@sentry/types": "6.19.7", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", + "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "node_modules/@smithy/abort-controller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.5.tgz", + "integrity": "sha512-DhNPnqTqPoG8aZ5dWkFOgsuY+i0GQ3CI6hMmvCoduNsnU9gUZWZBwGfDQsTTB7NvFPkom1df7jMIJWU90kuXXg==", + "dependencies": { + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-3.0.0.tgz", + "integrity": "sha512-sbnURCwjF0gSToGlsBiAmd1lRCmSn72nu9axfJu5lIx6RUEgHu6GwTMbqCdhQSi0Pumcm5vFxsi9XWXb2mTaoA==", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-3.0.0.tgz", + "integrity": "sha512-VDkpCYW+peSuM4zJip5WDfqvg2Mo/e8yxOv3VF1m11y7B8KKMKVFtmZWDe36Fvk8rGuWrPZHHXZ7rR7uM5yWyg==", + "dependencies": { + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.9.tgz", + "integrity": "sha512-5d9oBf40qC7n2xUoHmntKLdqsyTMMo/r49+eqSIjJ73eDfEtljAxEhzIQ3bkgXJtR3xiv7YzMT/3FF3ORkjWdg==", + "dependencies": { + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.4.7.tgz", + "integrity": "sha512-goqMjX+IoVEnHZjYuzu8xwoZjoteMiLXsPHuXPBkWsGwu0o9c3nTjqkUlP1Ez/V8E501aOU7CJ3INk8mQcW2gw==", + "dependencies": { + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.22", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.4.tgz", + "integrity": "sha512-S9bb0EIokfYEuar4kEbLta+ivlKCWOCFsLZuilkNy9i0uEUEHSi47IFLPaxqqCl+0ftKmcOTHayY5nQhAuq7+w==", + "dependencies": { + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.1.3.tgz", + "integrity": "sha512-mKBrmhg6Zd3j07G9dkKTGmrU7pdJGTNz8LbZtIOR3QoodS5yDNqEqoXU4Eg38snZcnCAh7NPBsw5ndxtJPLiCg==", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^3.4.0", + "@smithy/util-hex-encoding": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.7.tgz", + "integrity": "sha512-UC4RQqyM8B0g5cX/xmWtsNgSBmZ13HrzCqoe5Ulcz6R462/egbIdfTXnayik7jkjvwOrCPL1N11Q9S+n68jPLA==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.6", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.4.tgz", + "integrity": "sha512-saIs5rtAMpifqL7u7nc5YeE/6gkenzXpSz5NwEyhIesRWtHK+zEuYn9KY8SArZEbPSHyGxvvgKk1z86VzfUGHw==", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@smithy/eventstream-serde-node": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.6.tgz", + "integrity": "sha512-gRKGBdZah3EjZZgWcsTpShq4cZ4Q4JTTe1OPob+jrftmbYj6CvpeydZbH0roO5SvBG8SI3aBZIet9TGN3zUxUw==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.6", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.6.tgz", + "integrity": "sha512-1jvXd4sFG+zKaL6WqrJXpL6E+oAMafuM5GPd4qF0+ccenZTX3DZugoCCjlooQyTh+TZho2FpdVYUf5J/bB/j6Q==", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@smithy/eventstream-codec": "^3.1.3", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" - }, - "node_modules/@paulirish/trace_engine": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.23.tgz", - "integrity": "sha512-2ym/q7HhC5K+akXkNV6Gip3oaHpbI6TsGjmcAsl7bcJ528MVbacPQeoauLFEeLXH4ulJvsxQwNDIg/kAEhFZxw==", - "dev": true - }, - "node_modules/@pdf-lib/standard-fonts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", - "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "node_modules/@smithy/fetch-http-handler": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.9.tgz", + "integrity": "sha512-hYNVQOqhFQ6vOpenifFME546f0GfJn2OiQ3M0FDmuUu8V/Uiwy2wej7ZXxFBNqdx0R5DZAqWM1l6VRhGz8oE6A==", "dependencies": { - "pako": "^1.0.6" + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@pdf-lib/upng": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", - "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "node_modules/@smithy/hash-blob-browser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-3.1.3.tgz", + "integrity": "sha512-im9wAU9mANWW0OP0YGqwX3lw0nXG0ngyIcKQ8V/MUz1r7A6uO2lpPqKmAsH4VPGNLP2JPUhj4aW/m5UKkxX/IA==", "dependencies": { - "pako": "^1.0.10" + "@smithy/chunked-blob-reader": "^3.0.0", + "@smithy/chunked-blob-reader-native": "^3.0.0", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, + "node_modules/@smithy/hash-node": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.7.tgz", + "integrity": "sha512-SAGHN+QkrwcHFjfWzs/czX94ZEjPJ0CrWJS3M43WswDXVEuP4AVy9gJ3+AF6JQHZD13bojmuf/Ap/ItDeZ+Qfw==", + "dependencies": { + "@smithy/types": "^3.5.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=14" + "node": ">=16.0.0" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", - "dev": true - }, - "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", - "dev": true, + "node_modules/@smithy/hash-stream-node": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-3.1.3.tgz", + "integrity": "sha512-Tz/eTlo1ffqYn+19VaMjDDbmEWqYe4DW1PAWaS8HvgRdO6/k9hxNPt8Wv5laXoilxE20YzKugiHvxHyO6J7kGA==", "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" + "@smithy/types": "^3.4.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, + "node_modules/@smithy/invalid-dependency": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.7.tgz", + "integrity": "sha512-Bq00GsAhHeYSuZX8Kpu4sbI9agH2BNYnqUmmbTGWOhki9NVsWn2jFr896vvoTMH8KAjNX/ErC/8t5QHuEXG+IA==", "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" } }, - "node_modules/@puppeteer/browsers/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "engines": { - "node": ">= 14" + "node_modules/@smithy/md5-js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-3.0.4.tgz", + "integrity": "sha512-qSlqr/+hybufIJgxQW2gYzGE6ywfOxkjjJVojbbmv4MtxfdDFfzRew+NOIOXcYgazW0f8OYBTIKsmNsjxpvnng==", + "dependencies": { + "@smithy/types": "^3.4.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, + "node_modules/@smithy/middleware-content-length": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.9.tgz", + "integrity": "sha512-t97PidoGElF9hTtLCrof32wfWMqC5g2SEJNxaVH3NjlatuNGsdxXRYO/t+RPnxA15RpYiS0f+zG7FuE2DeGgjA==", "dependencies": { - "ms": "2.1.2" + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, + "node_modules/@smithy/middleware-endpoint": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.1.4.tgz", + "integrity": "sha512-/ChcVHekAyzUbyPRI8CzPPLj6y8QRAfJngWcLMgsWxKVzw/RzBV69mSOzJYDD3pRwushA1+5tHtPF8fjmzBnrQ==", "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" + "@smithy/middleware-serde": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, + "node_modules/@smithy/middleware-retry": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.22.tgz", + "integrity": "sha512-svEN7O2Tf7BoaBkPzX/8AE2Bv7p16d9/ulFAD1Gmn5g19iMqNk1WIkMxAY7SpB9/tVtUwKx0NaIsBRl88gumZA==", "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" + "@smithy/node-config-provider": "^3.1.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/service-error-classification": "^3.0.7", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/@puppeteer/browsers/node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, + "node_modules/@smithy/middleware-serde": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.7.tgz", + "integrity": "sha512-VytaagsQqtH2OugzVTq4qvjkLNbWehHfGcGr0JLJmlDRrNCeZoWkWsSOw1nhS/4hyUUWF/TLGGml4X/OnEep5g==", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/get-uri": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", - "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", - "dev": true, + "node_modules/@smithy/middleware-stack": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.7.tgz", + "integrity": "sha512-EyTbMCdqS1DoeQsO4gI7z2Gzq1MoRFAeS8GkFYIwbedB7Lp5zlLHJdg+56tllIIG5Hnf9ZWX48YKSHlsKvugGA==", "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4", - "fs-extra": "^11.2.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, + "node_modules/@smithy/node-config-provider": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.8.tgz", + "integrity": "sha512-E0rU0DglpeJn5ge64mk8wTGEXcQwmpUTY5Zr7IzTpDLmHKiIamINERNZYrPQjg58Ck236sEKSwRSHA4CwshU6Q==", "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, + "node_modules/@smithy/node-http-handler": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.2.4.tgz", + "integrity": "sha512-49reY3+JgLMFNm7uTAKBWiKCA6XSvkNp9FqhVmusm2jpVnHORYFeFZ704LShtqWfjZW/nhX+7Iexyb6zQfXYIQ==", + "dependencies": { + "@smithy/abort-controller": "^3.1.5", + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", - "dev": true, + "node_modules/@smithy/property-provider": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.7.tgz", + "integrity": "sha512-QfzLi1GPMisY7bAM5hOUqBdGYnY5S2JAlr201pghksrQv139f8iiiMalXtjczIP5f6owxFn3MINLNUNvUkgtPw==", "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, + "node_modules/@smithy/protocol-http": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.4.tgz", + "integrity": "sha512-MlWK8eqj0JlpZBnWmjQLqmFp71Ug00P+m72/1xQB3YByXD4zZ+y9N4hYrR0EDmrUCZIkyATWHOXFgtavwGDTzQ==", "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", - "dev": true, + "node_modules/@smithy/querystring-builder": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.7.tgz", + "integrity": "sha512-65RXGZZ20rzqqxTsChdqSpbhA6tdt5IFNgG6o7e1lnPVLCe6TNWQq4rTl4N87hTDD8mV4IxJJnvyE7brbnRkQw==", "dependencies": { - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" + "@smithy/types": "^3.5.0", + "@smithy/util-uri-escape": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 14" + "node": ">=16.0.0" } - }, - "node_modules/@puppeteer/browsers/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + }, + "node_modules/@smithy/querystring-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.7.tgz", + "integrity": "sha512-Fouw4KJVWqqUVIu1gZW8BH2HakwLz6dvdrAhXeXfeymOBrZw+hcqaWs+cS1AZPVp4nlbeIujYrKA921ZW2WMPA==", + "dependencies": { + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=10" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, + "node_modules/@smithy/service-error-classification": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.7.tgz", + "integrity": "sha512-91PRkTfiBf9hxkIchhRKJfl1rsplRDyBnmyFca3y0Z3x/q0JJN480S83LBd8R6sBCkm2bBbqw2FHp0Mbh+ecSA==", + "dependencies": { + "@smithy/types": "^3.5.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.8.tgz", + "integrity": "sha512-0NHdQiSkeGl0ICQKcJQ2lCOKH23Nb0EaAa7RDRId6ZqwXkw4LJyIyZ0t3iusD4bnKYDPLGy2/5e2rfUhrt0Acw==", + "dependencies": { + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=10" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "node_modules/@smithy/signature-v4": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.2.0.tgz", + "integrity": "sha512-LafbclHNKnsorMgUkKm7Tk7oJ7xizsZ1VwqhGKqoCIrXh4fqDDp73fK99HOEEgcsQbtemmeY/BPv0vTVYYUNEQ==", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, + "node_modules/@smithy/smithy-client": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.3.6.tgz", + "integrity": "sha512-qdH+mvDHgq1ss6mocyIl2/VjlWXew7pGwZQydwYJczEc22HZyX3k8yVPV9aZsbYbssHPvMDRA5rfBDrjQUbIIw==", + "dependencies": { + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@sentry/browser": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.19.7.tgz", - "integrity": "sha512-oDbklp4O3MtAM4mtuwyZLrgO1qDVYIujzNJQzXmi9YzymJCuzMLSRDvhY83NNDCRxf0pds4DShgYeZdbSyKraA==", + "node_modules/@smithy/types": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.5.0.tgz", + "integrity": "sha512-QN0twHNfe8mNJdH9unwsCK13GURU7oEAZqkBI+rsvpv1jrmserO+WnLE7jidR9W/1dxwZ0u/CB01mV2Gms/K2Q==", "dependencies": { - "@sentry/core": "6.19.7", - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "tslib": "^1.9.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/browser/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "node_modules/@smithy/url-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.7.tgz", + "integrity": "sha512-70UbSSR8J97c1rHZOWhl+VKiZDqHWxs/iW8ZHrHp5fCCPLSBE7GcUlUvKSle3Ca+J9LLbYCj/A79BxztBvAfpA==", + "dependencies": { + "@smithy/querystring-parser": "^3.0.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + } }, - "node_modules/@sentry/core": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", - "integrity": "sha512-tOfZ/umqB2AcHPGbIrsFLcvApdTm9ggpi/kQZFkej7kMphjT+SGBiQfYtjyg9jcRW+ilAR4JXC9BGKsdEQ+8Vw==", + "node_modules/@smithy/util-base64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", + "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", "dependencies": { - "@sentry/hub": "6.19.7", - "@sentry/minimal": "6.19.7", - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "tslib": "^1.9.3" + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/core/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "node_modules/@smithy/util-body-length-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", + "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", + "dependencies": { + "tslib": "^2.6.2" + } }, - "node_modules/@sentry/hub": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.19.7.tgz", - "integrity": "sha512-y3OtbYFAqKHCWezF0EGGr5lcyI2KbaXW2Ik7Xp8Mu9TxbSTuwTe4rTntwg8ngPjUQU3SUHzgjqVB8qjiGqFXCA==", + "node_modules/@smithy/util-body-length-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", + "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", "dependencies": { - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "tslib": "^1.9.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/hub/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sentry/integrations": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.19.7.tgz", - "integrity": "sha512-yNeeFyuygJaV7Mdc5qWuDa13xVj5mVdECaaw2Xs4pfeHaXmRfRzZY17N8ypWFegKWxKBHynyQRMD10W5pBwJvA==", + "node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", "dependencies": { - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "localforage": "^1.8.1", - "tslib": "^1.9.3" + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/integrations/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sentry/minimal": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.19.7.tgz", - "integrity": "sha512-wcYmSJOdvk6VAPx8IcmZgN08XTXRwRtB1aOLZm+MVHjIZIhHoBGZJYTVQS/BWjldsamj2cX3YGbGXNunaCfYJQ==", + "node_modules/@smithy/util-config-provider": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", + "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", "dependencies": { - "@sentry/hub": "6.19.7", - "@sentry/types": "6.19.7", - "tslib": "^1.9.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/minimal/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sentry/node": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-6.19.7.tgz", - "integrity": "sha512-gtmRC4dAXKODMpHXKfrkfvyBL3cI8y64vEi3fDD046uqYcrWdgoQsffuBbxMAizc6Ez1ia+f0Flue6p15Qaltg==", + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.22.tgz", + "integrity": "sha512-WKzUxNsOun5ETwEOrvooXeI1mZ8tjDTOcN4oruELWHhEYDgQYWwxZupURVyovcv+h5DyQT/DzK5nm4ZoR/Tw5Q==", "dependencies": { - "@sentry/core": "6.19.7", - "@sentry/hub": "6.19.7", - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "cookie": "^0.4.1", - "https-proxy-agent": "^5.0.0", - "lru_map": "^0.3.3", - "tslib": "^1.9.3" + "@smithy/property-provider": "^3.1.7", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">= 10.0.0" } }, - "node_modules/@sentry/node/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sentry/tracing": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.19.7.tgz", - "integrity": "sha512-ol4TupNnv9Zd+bZei7B6Ygnr9N3Gp1PUrNI761QSlHtPC25xXC5ssSD3GMhBgyQrcvpuRcCFHVNNM97tN5cZiA==", + "node_modules/@smithy/util-defaults-mode-node": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.22.tgz", + "integrity": "sha512-hUsciOmAq8fsGwqg4+pJfNRmrhfqMH4Y9UeGcgeUl88kPAoYANFATJqCND+O4nUvwp5TzsYwGpqpcBKyA8LUUg==", "dependencies": { - "@sentry/hub": "6.19.7", - "@sentry/minimal": "6.19.7", - "@sentry/types": "6.19.7", - "@sentry/utils": "6.19.7", - "tslib": "^1.9.3" + "@smithy/config-resolver": "^3.0.9", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/smithy-client": "^3.3.6", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">= 10.0.0" } }, - "node_modules/@sentry/tracing/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sentry/types": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.19.7.tgz", - "integrity": "sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==", + "node_modules/@smithy/util-endpoints": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.1.3.tgz", + "integrity": "sha512-34eACeKov6jZdHqS5hxBMJ4KyWKztTMulhuQ2UdOoP6vVxMLrOKUqIXAwJe/wiWMhXhydLW664B02CNpQBQ4Aw==", + "dependencies": { + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/utils": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.19.7.tgz", - "integrity": "sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==", + "node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", "dependencies": { - "@sentry/types": "6.19.7", - "tslib": "^1.9.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/@sentry/utils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "node_modules/@smithy/util-middleware": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.7.tgz", + "integrity": "sha512-OVA6fv/3o7TMJTpTgOi1H5OTwnuUa8hzRzhSFDtZyNxi6OZ70L/FHattSmhE212I7b6WSOJAAmbYnvcjTHOJCA==", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + "node_modules/@smithy/util-retry": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.7.tgz", + "integrity": "sha512-nh1ZO1vTeo2YX1plFPSe/OXaHkLAHza5jpokNiiKX2M5YpNUv6RxGJZhpfmiR4jSvVHCjIDmILjrxKmP+/Ghug==", + "dependencies": { + "@smithy/service-error-classification": "^3.0.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", - "dev": true, + "node_modules/@smithy/util-stream": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.1.9.tgz", + "integrity": "sha512-7YAR0Ub3MwTMjDfjnup4qa6W8gygZMxikBhFMPESi6ASsl/rZJhwLpF/0k9TuezScCojsM0FryGdz4LZtjKPPQ==", "dependencies": { - "type-detect": "4.0.8" + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/types": "^3.5.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", - "dev": true, + "node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", "dependencies": { - "@sinonjs/commons": "^1.7.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/samsam": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", - "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", - "dev": true, + "node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", "dependencies": { - "@sinonjs/commons": "^1.6.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", - "dev": true + "node_modules/@smithy/util-waiter": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.1.3.tgz", + "integrity": "sha512-OU0YllH51/CxD8iyr3UHSMwYqTGTyuxFdCMH/0F978t+iDmJseC/ttrWPb22zmYkhkrjqtipzC1xaMuax5QKIA==", + "dependencies": { + "@smithy/abort-controller": "^3.1.2", + "@smithy/types": "^3.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", @@ -3456,6 +6348,11 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6560,6 +9457,27 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastparse": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", @@ -17776,6 +20694,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/supertest": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.3.tgz", diff --git a/backend/package.json b/backend/package.json index 8c825900db..08b590f402 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,6 +37,9 @@ }, "private": true, "dependencies": { + "@aws-sdk/client-s3": "^3.650.0", + "@aws-sdk/credential-providers": "^3.664.0", + "@aws-sdk/s3-request-presigner": "^3.650.0", "@babel/runtime": "^7.21.0", "@istanbuljs/nyc-config-typescript": "^1.0.1", "@sentry/browser": "^6.13.1", diff --git a/backend/server/config/config.js b/backend/server/config/config.js index 69138fdfea..83dd235d9a 100644 --- a/backend/server/config/config.js +++ b/backend/server/config/config.js @@ -397,6 +397,30 @@ const config = convict({ default: 'sfc-public-staging', }, }, + workerCertificate: { + region: { + doc: 'AWS region override for worker certificate s3 bucket', + format: String, + default: 'eu-west-2', + env: 'TRAIN_AND_QUALS_CERTS_S3_BUCKET_REGION', + }, + bucketname: { + doc: 'Bucket used to upload worker certificate', + format: String, + default: 'sfc-dev-worker-certificates', + env: 'TRAIN_AND_QUALS_CERTS_S3_BUCKET_NAME', + }, + uploadSignedUrlExpire: { + doc: 'The duration in seconds for the upload signed URL to expire', + format: 'int', + default: 300, + }, + downloadSignedUrlExpire: { + doc: 'The duration in seconds for the download signed URL to expire', + format: 'int', + default: 300, + }, + }, disbursement: { region: { doc: 'AWS region override for disbursement S3 only', diff --git a/backend/server/models/classes/training.js b/backend/server/models/classes/training.js index f4a4b8fea9..9bbbc21f48 100644 --- a/backend/server/models/classes/training.js +++ b/backend/server/models/classes/training.js @@ -41,6 +41,7 @@ class Training extends EntityValidator { this._completed = null; this._expires = null; this._notes = null; + this._trainingCertificates = null; // lifecycle properties this._isNew = false; @@ -130,6 +131,11 @@ class Training extends EntityValidator { get category() { return this._category; } + + get trainingCertificates() { + return this._trainingCertificates; + } + get title() { if (this._title === null) return null; return unescape(this._title); @@ -150,6 +156,11 @@ class Training extends EntityValidator { set category(category) { this._category = category; } + + set trainingCertificates(trainingCertificates) { + this._trainingCertificates = trainingCertificates; + } + set title(title) { if (title !== null) { this._title = escape(title); @@ -603,6 +614,15 @@ class Training extends EntityValidator { model: models.workerTrainingCategories, as: 'category', }, + { + model: models.trainingCertificates, + as: 'trainingCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + }, + ], + order: [ + [models.trainingCertificates, 'uploadDate', 'DESC'], + [models.trainingCertificates, 'filename', 'ASC'], ], }; @@ -627,6 +647,7 @@ class Training extends EntityValidator { this._created = fetchResults.created; this._updated = fetchResults.updated; this._updatedBy = fetchResults.updatedBy; + this._trainingCertificates = fetchResults.trainingCertificates; return true; } @@ -812,97 +833,46 @@ class Training extends EntityValidator { } // returns a set of Workers' Training Records based on given filter criteria (all if no filters defined) - restricted to the given Worker - static async fetch(establishmentId, workerId, categoryId = null, filters = null) { - if (filters) throw new Error('Filters not implemented'); - - const allTrainingRecords = []; - let fetchResults; - if (categoryId === null) { - fetchResults = await models.workerTraining.findAll({ - include: [ - { - model: models.worker, - as: 'worker', - attributes: ['id', 'uid'], - where: { - uid: workerId, - }, - }, - { - model: models.workerTrainingCategories, - as: 'category', - attributes: ['id', 'category'], + static async fetch(establishmentId, workerId, categoryId = null) { + const fetchResults = await models.workerTraining.findAll({ + include: [ + { + model: models.worker, + as: 'worker', + attributes: ['id', 'uid'], + where: { + uid: workerId, }, - ], - order: [ - //['completed', 'DESC'], - ['updated', 'DESC'], - ], - }); - } else { - fetchResults = await models.workerTraining.findAll({ - include: [ - { - model: models.worker, - as: 'worker', - attributes: ['id', 'uid'], + }, + { + model: models.workerTrainingCategories, + as: 'category', + attributes: ['id', 'category'], + }, + { + model: models.trainingCertificates, + as: 'trainingCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + }, + ], + order: [['updated', 'DESC']], + ...(categoryId + ? { where: { - uid: workerId, + categoryFk: categoryId, }, - }, - { - model: models.workerTrainingCategories, - as: 'category', - attributes: ['id', 'category'], - }, - ], - order: [ - //['completed', 'DESC'], - ['updated', 'DESC'], - ], - where: { - categoryFk: categoryId, - }, - }); - } - - if (fetchResults) { - fetchResults.forEach((thisRecord) => { - allTrainingRecords.push({ - uid: thisRecord.uid, - trainingCategory: { - id: thisRecord.category.id, - category: thisRecord.category.category, - }, - title: thisRecord.title ? unescape(thisRecord.title) : undefined, - accredited: thisRecord.accredited ? thisRecord.accredited : undefined, - completed: thisRecord.completed ? new Date(thisRecord.completed).toISOString().slice(0, 10) : undefined, - expires: thisRecord.expires !== null ? new Date(thisRecord.expires).toISOString().slice(0, 10) : undefined, - notes: thisRecord.notes !== null ? unescape(thisRecord.notes) : undefined, - created: thisRecord.created.toISOString(), - updated: thisRecord.updated.toISOString(), - updatedBy: thisRecord.updatedBy, - }); - }); - } + } + : {}), + }); - let lastUpdated = null; - if (fetchResults && fetchResults.length === 1) { - lastUpdated = fetchResults[0]; - } else if (fetchResults && fetchResults.length > 1) { - lastUpdated = fetchResults.reduce((a, b) => { - return a.updated > b.updated ? a : b; - }); - } + const allTrainingRecords = fetchResults ? fetchResults.map((record) => this.formatTrainingRecord(record)) : []; - const response = { + return { workerUid: workerId, count: allTrainingRecords.length, - lastUpdated: lastUpdated ? lastUpdated.updated.toISOString() : undefined, + lastUpdated: this.getLastUpdatedTrainingRecord(allTrainingRecords), training: allTrainingRecords, }; - - return response; } // returns a Javascript object which can be used to present as JSON @@ -920,6 +890,7 @@ class Training extends EntityValidator { completed: this.completed ? this.completed : undefined, expires: this._expires !== null ? this.expires : undefined, notes: this._notes !== null ? this.notes : undefined, + trainingCertificates: this.trainingCertificates, }; return myDefaultJSON; @@ -1026,6 +997,44 @@ class Training extends EntityValidator { return 0; } } + + static getLastUpdatedTrainingRecord(trainingRecords) { + return trainingRecords?.length + ? trainingRecords.reduce((a, b) => { + return a.updated > b.updated ? a : b; + }).updated + : undefined; + } + + static formatTrainingRecord(recordFromDatabase) { + return { + uid: recordFromDatabase.uid, + trainingCategory: { + id: recordFromDatabase.category.id, + category: recordFromDatabase.category.category, + }, + trainingCertificates: recordFromDatabase.trainingCertificates?.map((certificate) => { + return { + uid: certificate.uid, + filename: certificate.filename, + uploadDate: certificate.uploadDate?.toISOString(), + }; + }), + title: recordFromDatabase.title ? unescape(recordFromDatabase.title) : undefined, + accredited: recordFromDatabase.accredited ? recordFromDatabase.accredited : undefined, + completed: recordFromDatabase.completed + ? new Date(recordFromDatabase.completed).toISOString().slice(0, 10) + : undefined, + expires: + recordFromDatabase.expires !== null + ? new Date(recordFromDatabase.expires).toISOString().slice(0, 10) + : undefined, + notes: recordFromDatabase.notes !== null ? unescape(recordFromDatabase.notes) : undefined, + created: recordFromDatabase.created.toISOString(), + updated: recordFromDatabase.updated.toISOString(), + updatedBy: recordFromDatabase.updatedBy, + }; + } } module.exports.Training = Training; diff --git a/backend/server/models/trainingCertificates.js b/backend/server/models/trainingCertificates.js new file mode 100644 index 0000000000..4231dc60fc --- /dev/null +++ b/backend/server/models/trainingCertificates.js @@ -0,0 +1,99 @@ +/* jshint indent: 2 */ +const dayjs = require('dayjs'); + +module.exports = function (sequelize, DataTypes) { + const TrainingCertificates = sequelize.define( + 'trainingCertificates', + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + field: '"ID"', + }, + uid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: '"UID"', + }, + workerFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerFK"', + }, + workerTrainingFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerTrainingFK"', + }, + filename: { + type: DataTypes.TEXT, + allowNull: true, + field: '"FileName"', + }, + uploadDate: { + type: DataTypes.DATE, + allowNull: false, + field: '"UploadDate"', + }, + key: { + type: DataTypes.TEXT, + allowNull: false, + field: '"Key"', + }, + }, + { + tableName: 'TrainingCertificates', + schema: 'cqc', + createdAt: false, + updatedAt: false, + }, + ); + + TrainingCertificates.associate = (models) => { + TrainingCertificates.belongsTo(models.worker, { + foreignKey: 'workerFk', + targetKey: 'id', + as: 'worker', + }); + + TrainingCertificates.belongsTo(models.workerTraining, { + foreignKey: 'workerTrainingFk', + targetKey: 'id', + as: 'workerTraining', + }); + }; + + TrainingCertificates.addCertificate = function ({ trainingRecordId, workerFk, filename, fileId, key }) { + const timeNow = dayjs().format(); + + return this.create({ + uid: fileId, + workerFk: workerFk, + workerTrainingFk: trainingRecordId, + filename: filename, + uploadDate: timeNow, + key, + }); + }; + + TrainingCertificates.deleteCertificate = async function (uids) { + return await this.destroy({ + where: { + uid: uids, + }, + }); + }; + + TrainingCertificates.countCertificatesToBeDeleted = async function (uids) { + return await this.count({ + where: { + uid: uids, + }, + }); + }; + + return TrainingCertificates; +}; diff --git a/backend/server/models/worker.js b/backend/server/models/worker.js index ae00616807..72626898b9 100644 --- a/backend/server/models/worker.js +++ b/backend/server/models/worker.js @@ -1207,6 +1207,12 @@ module.exports = function (sequelize, DataTypes) { otherKey: 'ID', as: 'qualifications', }); + Worker.hasMany(models.trainingCertificates, { + foreignKey: 'workerFk', + sourceKey: 'id', + as: 'trainingCertificates', + onDelete: 'CASCADE', + }); }; Worker.permAndTempCountForEstablishment = function (establishmentId) { return this.count({ diff --git a/backend/server/models/workerTraining.js b/backend/server/models/workerTraining.js index 8b634ae919..c579ed5184 100644 --- a/backend/server/models/workerTraining.js +++ b/backend/server/models/workerTraining.js @@ -98,6 +98,12 @@ module.exports = function (sequelize, DataTypes) { targetKey: 'id', as: 'category', }); + WorkerTraining.hasMany(models.trainingCertificates, { + foreignKey: 'workerTrainingFk', + sourceKey: 'id', + as: 'trainingCertificates', + onDelete: 'CASCADE', + }); }; WorkerTraining.fetchTrainingByCategoryForEstablishment = async function ( diff --git a/backend/server/routes/establishments/training/index.js b/backend/server/routes/establishments/training/index.js index 8a38fbfcd8..fc44a698b4 100644 --- a/backend/server/routes/establishments/training/index.js +++ b/backend/server/routes/establishments/training/index.js @@ -5,6 +5,7 @@ const router = express.Router({ mergeParams: true }); // all user functionality is encapsulated const Training = require('../../../models/classes/training').Training; const MandatoryTraining = require('../../../models/classes/mandatoryTraining').MandatoryTraining; +const TrainingCertificateRoute = require('../workerCertificate/trainingCertificate'); const { hasPermission } = require('../../../utils/security/hasPermission'); @@ -157,6 +158,7 @@ const deleteTrainingRecord = async (req, res) => { const establishmentId = req.establishmentId; const trainingUid = req.params.trainingUid; const workerUid = req.params.workerId; + const establishmentUid = req.params.id; const thisTrainingRecord = new Training(establishmentId, workerUid); @@ -167,13 +169,42 @@ const deleteTrainingRecord = async (req, res) => { if (await thisTrainingRecord.restore(trainingUid)) { // TODO: JSON validation + const trainingCertificates = thisTrainingRecord?._trainingCertificates; + + if (trainingCertificates?.length > 0) { + let trainingCertificatesfilesToDeleteFromS3 = []; + let trainingCertificatesUidsToDeleteFromDb = []; + + for (const trainingCertificate of trainingCertificates) { + let fileKey = TrainingCertificateRoute.makeFileKey( + establishmentUid, + workerUid, + trainingUid, + trainingCertificate.uid, + ); + + trainingCertificatesUidsToDeleteFromDb.push(trainingCertificate.uid); + trainingCertificatesfilesToDeleteFromS3.push({ Key: fileKey }); + } + + const deletedTrainingCertificatesFromDatabase = await TrainingCertificateRoute.deleteRecordsFromDatabase( + trainingCertificatesUidsToDeleteFromDb, + ); + + if (deletedTrainingCertificatesFromDatabase) { + await TrainingCertificateRoute.deleteCertificatesFromS3(trainingCertificatesfilesToDeleteFromS3); + } else { + console.log('Failed to delete training certificates'); + return res.status(500).send(); + } + } + // by deleting after the restore we can be sure this training record belongs to the given worker const deleteSuccess = await thisTrainingRecord.delete(); - if (deleteSuccess) { return res.status(204).json(); } else { - return res.status(404).json('Not Found'); + return res.status(404).send('Not Found'); } } else { // not found worker @@ -190,7 +221,9 @@ router.route('/').post(hasPermission('canEditWorker'), createTrainingRecord); router.route('/:trainingUid').get(hasPermission('canViewWorker'), viewTrainingRecord); router.route('/:trainingUid').put(hasPermission('canEditWorker'), updateTrainingRecord); router.route('/:trainingUid').delete(hasPermission('canEditWorker'), deleteTrainingRecord); +router.use('/:trainingUid/certificate', TrainingCertificateRoute); module.exports = router; module.exports.getTrainingListWithMissingMandatoryTraining = getTrainingListWithMissingMandatoryTraining; module.exports.createSingleTrainingRecord = createSingleTrainingRecord; +module.exports.deleteTrainingRecord = deleteTrainingRecord; diff --git a/backend/server/routes/establishments/workerCertificate/s3.js b/backend/server/routes/establishments/workerCertificate/s3.js new file mode 100644 index 0000000000..964350d845 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/s3.js @@ -0,0 +1,79 @@ +const { + PutObjectCommand, + GetObjectCommand, + S3Client, + HeadObjectCommand, + DeleteObjectsCommand, +} = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); +const { fromContainerMetadata } = require('@aws-sdk/credential-providers'); + +const config = require('../../../config/config'); +const region = String(config.get('workerCertificate.region')); +const env = String(config.get('env')); + +const getS3Client = () => { + if (env === 'localhost') { + return new S3Client({ + region, + signatureVersion: 'v4', + }); + } + + return new S3Client({ + credentials: fromContainerMetadata({ + timeout: 1000, + maxRetries: 0, + }), + region, + signatureVersion: 'v4', + }); +}; + +const s3Client = getS3Client(); + +const getSignedUrlForUpload = ({ bucket, key, options }) => { + const putCommand = new PutObjectCommand({ + Bucket: bucket, + Key: key, + }); + return getSignedUrl(s3Client, putCommand, options); +}; + +const getSignedUrlForDownload = ({ bucket, key, options }) => { + const getCommand = new GetObjectCommand({ + Bucket: bucket, + Key: key, + }); + return getSignedUrl(s3Client, getCommand, options); +}; + +const deleteCertificatesFromS3 = async ({ bucket, objects }) => { + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: objects }, + }); + + try { + const response = await s3Client.send(deleteCommand); + return response; + } catch (err) { + console.error(err); + } +}; + +const verifyEtag = async (bucket, key, etag) => { + const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(headCommand); + const etagFromS3 = response.ETag; + + return etagFromS3 === etag; +}; + +module.exports = { + getS3Client, + getSignedUrlForUpload, + getSignedUrlForDownload, + verifyEtag, + deleteCertificatesFromS3, +}; diff --git a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js new file mode 100644 index 0000000000..d6122995e6 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js @@ -0,0 +1,201 @@ +const { v4: uuidv4 } = require('uuid'); +const express = require('express'); + +const config = require('../../../config/config'); +const models = require('../../../models'); + +const s3 = require('./s3'); +const { hasPermission } = require('../../../utils/security/hasPermission'); + +const certificateBucket = String(config.get('workerCertificate.bucketname')); +const uploadSignedUrlExpire = config.get('workerCertificate.uploadSignedUrlExpire'); +const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlExpire'); + +const router = express.Router({ mergeParams: true }); + +const makeFileKey = (establishmentUid, workerId, trainingUid, fileId) => { + return `${establishmentUid}/${workerId}/trainingCertificate/${trainingUid}/${fileId}`; +}; + +const requestUploadUrl = async (req, res) => { + const { files } = req.body; + const { id, workerId, trainingUid } = req.params; + if (!files || !files.length) { + return res.status(400).send('Missing `files` param in request body'); + } + + if (!files.every((file) => file?.filename)) { + return res.status(400).send('Missing file name in request body'); + } + + const responsePayload = []; + + for (const file of files) { + const filename = file.filename; + const fileId = uuidv4(); + const key = makeFileKey(id, workerId, trainingUid, fileId); + const signedUrl = await s3.getSignedUrlForUpload({ + bucket: certificateBucket, + key, + options: { expiresIn: uploadSignedUrlExpire }, + }); + responsePayload.push({ filename, signedUrl, fileId, key }); + } + + return res.status(200).json({ files: responsePayload }); +}; + +const confirmUpload = async (req, res) => { + const { establishmentId } = req; + const { trainingUid } = req.params; + const { files } = req.body; + + if (!files || !files.length) { + return res.status(400).send('Missing `files` param in request body'); + } + + const trainingRecord = await models.workerTraining.findOne({ + where: { + uid: trainingUid, + }, + attributes: ['id', 'workerFk'], + }); + + if (!trainingRecord) { + return res.status(400).send('Failed to find related training record'); + } + + const { workerFk, id: trainingRecordId } = trainingRecord.dataValues; + + const etagsMatchRecord = await verifyEtagsForAllFiles(establishmentId, files); + if (!etagsMatchRecord) { + return res.status(400).send('Failed to verify files on S3'); + } + + for (const file of files) { + const { filename, fileId, key } = file; + + try { + await models.trainingCertificates.addCertificate({ trainingRecordId, workerFk, filename, fileId, key }); + } catch (err) { + console.error(err); + return res.status(500).send('Failed to add records to database'); + } + } + + return res.status(200).send(); +}; + +const verifyEtagsForAllFiles = async (establishmentId, files) => { + try { + for (const file of files) { + const etagMatchS3Record = await s3.verifyEtag(certificateBucket, file.key, file.etag); + if (!etagMatchS3Record) { + console.error('Etags in the request does not match the record at AWS bucket'); + return false; + } + } + } catch (err) { + console.error(err); + return false; + } + return true; +}; + +const getPresignedUrlForCertificateDownload = async (req, res) => { + const { filesToDownload } = req.body; + const { id, workerId, trainingUid } = req.params; + + if (!filesToDownload || !filesToDownload.length) { + return res.status(400).send('No files provided in request body'); + } + + const responsePayload = []; + + for (const file of filesToDownload) { + const signedUrl = await s3.getSignedUrlForDownload({ + bucket: certificateBucket, + key: makeFileKey(id, workerId, trainingUid, file.uid), + options: { expiresIn: downloadSignedUrlExpire }, + }); + responsePayload.push({ signedUrl, filename: file.filename }); + } + + return res.status(200).json({ files: responsePayload }); +}; + +const deleteRecordsFromDatabase = async (uids) => { + try { + await models.trainingCertificates.deleteCertificate(uids); + return true; + } catch (error) { + console.log(error); + return false; + } +}; + +const deleteCertificatesFromS3 = async (filesToDeleteFromS3) => { + const deleteFromS3Response = await s3.deleteCertificatesFromS3({ + bucket: certificateBucket, + objects: filesToDeleteFromS3, + }); + + if (deleteFromS3Response?.Errors?.length > 0) { + console.error(JSON.stringify(deleteFromS3Response.Errors)); + } +}; + +const deleteCertificates = async (req, res) => { + const { filesToDelete } = req.body; + const { id, workerId, trainingUid } = req.params; + + if (!filesToDelete || !filesToDelete.length) { + return res.status(400).send('No files provided in request body'); + } + + let filesToDeleteFromS3 = []; + let filesToDeleteFromDatabase = []; + + for (const file of filesToDelete) { + let fileKey = makeFileKey(id, workerId, trainingUid, file.uid); + + filesToDeleteFromDatabase.push(file.uid); + filesToDeleteFromS3.push({ Key: fileKey }); + } + + try { + const noOfFilesFoundInDatabase = await models.trainingCertificates.countCertificatesToBeDeleted( + filesToDeleteFromDatabase, + ); + + if (noOfFilesFoundInDatabase !== filesToDeleteFromDatabase.length) { + return res.status(400).send('Invalid request'); + } + + const deletionFromDatabase = await deleteRecordsFromDatabase(filesToDeleteFromDatabase); + if (!deletionFromDatabase) { + return res.status(500).send(); + } + } catch (error) { + console.log(error); + return res.status(500).send(); + } + + await deleteCertificatesFromS3(filesToDeleteFromS3); + + return res.status(200).send(); +}; + +router.route('/').post(hasPermission('canEditWorker'), requestUploadUrl); +router.route('/').put(hasPermission('canEditWorker'), confirmUpload); +router.route('/download').post(hasPermission('canEditWorker'), getPresignedUrlForCertificateDownload); +router.route('/delete').post(hasPermission('canEditWorker'), deleteCertificates); + +module.exports = router; +module.exports.requestUploadUrl = requestUploadUrl; +module.exports.confirmUpload = confirmUpload; +module.exports.getPresignedUrlForCertificateDownload = getPresignedUrlForCertificateDownload; +module.exports.deleteCertificates = deleteCertificates; +module.exports.deleteRecordsFromDatabase = deleteRecordsFromDatabase; +module.exports.deleteCertificatesFromS3 = deleteCertificatesFromS3; +module.exports.makeFileKey = makeFileKey; diff --git a/backend/server/test/unit/mockdata/training.js b/backend/server/test/unit/mockdata/training.js index 92565af769..00f727148f 100644 --- a/backend/server/test/unit/mockdata/training.js +++ b/backend/server/test/unit/mockdata/training.js @@ -336,3 +336,46 @@ exports.mockExpiredTrainingRecords = [ ], }, ]; + +exports.mockTrainingRecordWithCertificates = { + id: 10, + uid: '50382236-2468-4a78-9d23-932d0ab90966', + workerUid: '32fa83f9-dc21-4685-82d4-021024c0d5fe', + created: '01/02/2020', + updated: '01/02/2020', + updatedBy: 'admin', + categoryFk: 1, + category: { category: 'Communication' }, + title: 'Communication Training 1', + accredited: true, + completed: '01/02/2020', + expires: '01/02/2021', + trainingCertificates: [ + { + uid: 'uid-1', + filename: 'communication_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'communication_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ], +}; + +exports.mockTrainingRecordWithoutCertificates = { + id: 10, + uid: '50382236-2468-4a78-9d23-932d0ab90966', + workerUid: '32fa83f9-dc21-4685-82d4-021024c0d5fe', + created: '01/02/2020', + updated: '01/02/2020', + updatedBy: 'admin', + categoryFk: 1, + category: { category: 'Communication' }, + title: 'Communication Training 1', + accredited: true, + completed: '01/02/2020', + expires: '01/02/2021', + trainingCertificates: [], +}; diff --git a/backend/server/test/unit/models/classes/training.spec.js b/backend/server/test/unit/models/classes/training.spec.js index 3edb82d001..baae812e5f 100644 --- a/backend/server/test/unit/models/classes/training.spec.js +++ b/backend/server/test/unit/models/classes/training.spec.js @@ -1,11 +1,14 @@ 'use strict'; const expect = require('chai').expect; -const sandbox = require('sinon').createSandbox(); +const sinon = require('sinon'); + const moment = require('moment'); //include Training class const Training = require('../../../../models/classes/training').Training; +const models = require('../../../../models'); const establishmentId = 123; +const workerUid = '69e62cc3-03bf-4128-b456-cf0350cd032f'; const workerRecords = [ { id: 9718, @@ -49,16 +52,16 @@ const workerTrainingRecords = { }; describe('/server/models/class/training.js', () => { - afterEach(() => { - sandbox.restore(); - }); + describe('getExpiringAndExpiredTrainingCounts', () => { + afterEach(() => { + sinon.restore(); + }); - beforeEach(() => { - sandbox.stub(Training, 'fetch').returns(workerTrainingRecords); - sandbox.stub(Training, 'getAllMissingMandatoryTrainingCounts').returns(null); - }); + beforeEach(() => { + sinon.stub(Training, 'fetch').returns(workerTrainingRecords); + sinon.stub(Training, 'getAllMissingMandatoryTrainingCounts').returns(null); + }); - describe('getExpiringAndExpiredTrainingCounts', () => { it('should return updated worker records : Training.getAllRequiredCounts', async () => { const updateTrainingRecords = await Training.getAllRequiredCounts(establishmentId, workerRecords); if (updateTrainingRecords) { @@ -85,4 +88,209 @@ describe('/server/models/class/training.js', () => { } }); }); + + describe('fetch', () => { + afterEach(() => { + sinon.restore(); + }); + describe('DB call', () => { + it('should make database call without where clause when no categoryId', async () => { + const workerTrainingFindAll = sinon.stub(models.workerTraining, 'findAll').resolves([]); + await Training.fetch(establishmentId, workerUid); + + expect(workerTrainingFindAll.args[0][0]).to.deep.equal({ + include: [ + { + model: models.worker, + as: 'worker', + attributes: ['id', 'uid'], + where: { + uid: workerUid, + }, + }, + { + model: models.workerTrainingCategories, + as: 'category', + attributes: ['id', 'category'], + }, + { + model: models.trainingCertificates, + as: 'trainingCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + }, + ], + order: [['updated', 'DESC']], + }); + }); + + it('should make database call with where clause when categoryId provided', async () => { + const categoryId = 12; + const workerTrainingFindAll = sinon.stub(models.workerTraining, 'findAll').resolves([]); + await Training.fetch(establishmentId, workerUid, categoryId); + + expect(workerTrainingFindAll.args[0][0]).to.deep.equal({ + include: [ + { + model: models.worker, + as: 'worker', + attributes: ['id', 'uid'], + where: { + uid: workerUid, + }, + }, + { + model: models.workerTrainingCategories, + as: 'category', + attributes: ['id', 'category'], + }, + { + model: models.trainingCertificates, + as: 'trainingCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + }, + ], + order: [['updated', 'DESC']], + where: { + categoryFk: categoryId, + }, + }); + }); + }); + + it('should return formatted version of training record from database', async () => { + const trainingRecordFromDatabase = mockTrainingRecordFromDatabase(); + + const formattedTrainingRecord = { + uid: 'abc123', + trainingCategory: { + id: 'def456', + category: 'Test Category', + }, + trainingCertificates: [ + { + uid: 'ghi789', + filename: 'certificate.pdf', + uploadDate: '2024-01-03T00:00:00.000Z', + }, + ], + title: 'Title', + accredited: undefined, + completed: '2023-12-03', + expires: '2024-12-03', + notes: undefined, + created: '2023-12-03T00:00:00.000Z', + updated: '2023-12-04T00:00:00.000Z', + updatedBy: 'user1', + }; + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecordFromDatabase]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.training[0]).to.deep.equal(formattedTrainingRecord); + }); + + it('should return count as length of training records array when no records', async () => { + sinon.stub(models.workerTraining, 'findAll').resolves([]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.count).to.equal(0); + }); + + it('should return count as length of training records array when 2 records', async () => { + sinon + .stub(models.workerTraining, 'findAll') + .resolves([mockTrainingRecordFromDatabase(), mockTrainingRecordFromDatabase()]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.count).to.equal(2); + }); + + it('should return trainingCertificates as empty array and count as 0 when no certificate records returned from database (empty array)', async () => { + const trainingRecordFromDatabase = mockTrainingRecordFromDatabase(); + trainingRecordFromDatabase.trainingCertificates = []; + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecordFromDatabase]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.training[0].trainingCertificates).to.deep.equal([]); + }); + + it('should not return trainingCertificates when no certificate records returned from database (null)', async () => { + const trainingRecordFromDatabase = mockTrainingRecordFromDatabase(); + trainingRecordFromDatabase.trainingCertificates = null; + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecordFromDatabase]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.training[0].trainingCertificates).to.deep.equal(undefined); + }); + + it('should not return trainingCertificates when no certificate records returned from database (null)', async () => { + const trainingRecordFromDatabase = mockTrainingRecordFromDatabase(); + trainingRecordFromDatabase.trainingCertificates = null; + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecordFromDatabase]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.training[0].trainingCertificates).to.deep.equal(undefined); + }); + + it('should set lastUpdated as updated of record in ISO format when single training record', async () => { + const trainingRecordFromDatabase = mockTrainingRecordFromDatabase(); + trainingRecordFromDatabase.updated = new Date('2024-04-02'); + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecordFromDatabase]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.lastUpdated).to.equal('2024-04-02T00:00:00.000Z'); + }); + + it('should set lastUpdated as updated of record with latest updated in ISO format when several training records', async () => { + const trainingRecord1 = mockTrainingRecordFromDatabase(); + trainingRecord1.updated = new Date('2024-04-02'); + + const trainingRecord2 = mockTrainingRecordFromDatabase(); + trainingRecord2.updated = new Date('2024-06-02'); + + const trainingRecord3 = mockTrainingRecordFromDatabase(); + trainingRecord3.updated = new Date('2024-02-02'); + + sinon.stub(models.workerTraining, 'findAll').resolves([trainingRecord1, trainingRecord2, trainingRecord3]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.lastUpdated).to.equal('2024-06-02T00:00:00.000Z'); + }); + + it('should set lastUpdated as undefined when no training records', async () => { + sinon.stub(models.workerTraining, 'findAll').resolves([]); + const res = await Training.fetch(establishmentId, workerUid); + + expect(res.lastUpdated).to.equal(undefined); + }); + }); }); + +const mockTrainingRecordFromDatabase = () => { + return { + uid: 'abc123', + category: { + id: 'def456', + category: 'Test Category', + }, + trainingCertificates: [ + { + uid: 'ghi789', + filename: 'certificate.pdf', + uploadDate: new Date('2024-01-03'), + }, + ], + title: 'Title', + accredited: false, + completed: new Date('2023-12-03'), + expires: new Date('2024-12-03'), + notes: null, + created: new Date('2023-12-03'), + updated: new Date('2023-12-04'), + updatedBy: 'user1', + }; +}; diff --git a/backend/server/test/unit/routes/establishments/training/index.spec.js b/backend/server/test/unit/routes/establishments/training/index.spec.js new file mode 100644 index 0000000000..04fcd16674 --- /dev/null +++ b/backend/server/test/unit/routes/establishments/training/index.spec.js @@ -0,0 +1,132 @@ +const expect = require('chai').expect; +const sinon = require('sinon'); +const httpMocks = require('node-mocks-http'); +const buildUser = require('../../../../factories/user'); +const models = require('../../../../../models'); +const Training = require('../../../../../models/classes/training').Training; +const { deleteTrainingRecord } = require('../../../../../routes/establishments/training/index'); +const { + mockTrainingRecordWithCertificates, + mockTrainingRecordWithoutCertificates, +} = require('../../../mockdata/training'); +const TrainingCertificateRoute = require('../../../../../routes/establishments/workerCertificate/trainingCertificate'); + +describe('server/routes/establishments/training/index.js', () => { + afterEach(() => { + sinon.restore(); + }); + + const user = buildUser(); + + describe('deleteTrainingRecord', () => { + let req; + let res; + let stubFindTrainingRecord; + let stubRestoredTrainingRecord; + let stubDeleteTrainingCertificatesFromDatabase; + let stubDestroyTrainingRecord; + let workerUid = mockTrainingRecordWithCertificates.workerUid; + let trainingUid = mockTrainingRecordWithCertificates.uid; + let establishmentUid = user.establishment.uid; + let trainingRecord; + + beforeEach(() => { + req = httpMocks.createRequest({ + method: 'DELETE', + url: `/api/establishment/${establishmentUid}/worker/${workerUid}/training/${trainingUid}/deleteTrainingRecord`, + params: { trainingUid: trainingUid, workerId: workerUid, id: establishmentUid }, + establishmentId: '10', + }); + res = httpMocks.createResponse(); + + trainingRecord = new Training(establishmentUid, workerUid); + + stubRestoredTrainingRecord = sinon.stub(trainingRecord, 'restore'); + stubFindTrainingRecord = sinon.stub(models.workerTraining, 'findOne'); + stubDeleteTrainingCertificatesFromDatabase = sinon.stub(TrainingCertificateRoute, 'deleteRecordsFromDatabase'); + sinon.stub(TrainingCertificateRoute, 'deleteCertificatesFromS3').returns([]); + stubDestroyTrainingRecord = sinon.stub(models.workerTraining, 'destroy'); + }); + + it('should return with a status of 204 when the training record is deleted with training certificates', async () => { + stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubDeleteTrainingCertificatesFromDatabase.returns(true); + stubDestroyTrainingRecord.returns(1); + + await deleteTrainingRecord(req, res); + + expect(res.statusCode).to.equal(204); + }); + + it('should return with a status of 204 when the training record is deleted with no training certificates', async () => { + stubRestoredTrainingRecord.returns(mockTrainingRecordWithoutCertificates); + stubFindTrainingRecord.returns(mockTrainingRecordWithoutCertificates); + stubDestroyTrainingRecord.returns(1); + + await deleteTrainingRecord(req, res); + + expect(res.statusCode).to.equal(204); + }); + + describe('errors', () => { + describe('restoring training record', () => { + it('should return a 500 status code if there is an invalid training uid', async () => { + req.params.trainingUid = 'mockTrainingUid'; + + await deleteTrainingRecord(req, res); + + expect(res.statusCode).to.equal(500); + }); + + it('should return a 404 status code if there is an unknown worker uid', async () => { + trainingRecord_workerUid = 'mockWorkerUid'; + + await deleteTrainingRecord(req, res); + + const response = res._getData(); + + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); + }); + + it('should return a 500 status code if there is an error loading the training record', async () => { + stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubFindTrainingRecord.throws(); + + await deleteTrainingRecord(req, res); + + expect(res.statusCode).to.equal(500); + }); + }); + + describe('deleting certificates', () => { + it('should return with a status of 500 when there is an error deleting certificates from the database', async () => { + stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubDeleteTrainingCertificatesFromDatabase.returns(false); + + await deleteTrainingRecord(req, res); + + expect(res.statusCode).to.equal(500); + }); + }); + + describe('deleting training record', () => { + it('should return with a status of 404 when there is an error deleting the training record from the database', async () => { + stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); + stubDeleteTrainingCertificatesFromDatabase.returns(true); + stubDestroyTrainingRecord.returns(0); + + await deleteTrainingRecord(req, res); + + const response = res._getData(); + + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); + }); + }); + }); + }); +}); diff --git a/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js b/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js new file mode 100644 index 0000000000..b35ba94f55 --- /dev/null +++ b/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js @@ -0,0 +1,368 @@ +const sinon = require('sinon'); +const expect = require('chai').expect; +const httpMocks = require('node-mocks-http'); +const uuid = require('uuid'); + +const models = require('../../../../../models'); +const buildUser = require('../../../../factories/user'); +const { trainingBuilder } = require('../../../../factories/models'); +const s3 = require('../../../../../routes/establishments/workerCertificate/s3'); +const config = require('../../../../../config/config'); + +const trainingCertificateRoute = require('../../../../../routes/establishments/workerCertificate/trainingCertificate'); + +describe('backend/server/routes/establishments/workerCertificate/trainingCertificate.js', () => { + const user = buildUser(); + const training = trainingBuilder(); + + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => {}); + + describe('requestUploadUrl', () => { + const mockUploadFiles = ['cert1.pdf', 'cert2.pdf']; + const mockSignedUrl = 'http://localhost/mock-upload-url'; + let res; + + function createReq(override = {}) { + const mockRequestBody = { files: [{ filename: 'cert1.pdf' }, { filename: 'cert2.pdf' }] }; + + const req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/training/${training.uid}/certificate`, + body: mockRequestBody, + establishmentId: user.establishment.uid, + ...override, + }); + + return req; + } + + beforeEach(() => { + sinon.stub(s3, 'getSignedUrlForUpload').returns(mockSignedUrl); + res = httpMocks.createResponse(); + }); + + it('should reply with a status of 200', async () => { + const req = createReq(); + + await trainingCertificateRoute.requestUploadUrl(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should include a signed url for upload and a uuid for each file', async () => { + const req = createReq(); + + await trainingCertificateRoute.requestUploadUrl(req, res); + + const actual = await res._getJSONData(); + + expect(actual.files).to.have.lengthOf(mockUploadFiles.length); + + actual.files.forEach((file) => { + const { fileId, filename, signedUrl } = file; + expect(uuid.validate(fileId)).to.be.true; + expect(filename).to.be.oneOf(mockUploadFiles); + expect(signedUrl).to.equal(mockSignedUrl); + }); + }); + + it('should reply with status 400 if files param was missing in body', async () => { + const req = createReq({ body: {} }); + + await trainingCertificateRoute.requestUploadUrl(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Missing `files` param in request body'); + }); + + it('should reply with status 400 if filename was missing in any of the files', async () => { + const req = createReq({ body: { files: [{ filename: 'file1.pdf' }, { anotherItem: 'no file name' }] } }); + + await trainingCertificateRoute.requestUploadUrl(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Missing file name in request body'); + }); + + describe('confirmUpload', () => { + const mockUploadFiles = [ + { filename: 'cert1.pdf', fileId: 'uuid1', etag: 'etag1', key: 'mockKey' }, + { filename: 'cert2.pdf', fileId: 'uuid2', etag: 'etag2', key: 'mockKey2' }, + ]; + const mockWorkerFk = user.id; + const mockTrainingRecord = { dataValues: { workerFk: user.id, id: training.id } }; + + let stubAddCertificate; + + beforeEach(() => { + sinon.stub(models.workerTraining, 'findOne').returns(mockTrainingRecord); + sinon.stub(s3, 'verifyEtag').returns(true); + stubAddCertificate = sinon.stub(models.trainingCertificates, 'addCertificate'); + sinon.stub(console, 'error'); // mute error log + }); + + const createPutReq = (override) => { + return createReq({ method: 'PUT', body: { files: mockUploadFiles }, ...override }); + }; + + it('should reply with a status of 200', async () => { + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should add a new record to database for each file', async () => { + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(stubAddCertificate).to.have.been.callCount(mockUploadFiles.length); + + mockUploadFiles.forEach((file) => { + expect(stubAddCertificate).to.have.been.calledWith({ + trainingRecordId: training.id, + workerFk: mockWorkerFk, + filename: file.filename, + fileId: file.fileId, + key: file.key, + }); + }); + }); + + it('should reply with status 400 if file param was missing', async () => { + const req = createPutReq({ body: {} }); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it(`should reply with status 400 if training record does not exist in database`, async () => { + models.workerTraining.findOne.restore(); + sinon.stub(models.workerTraining, 'findOne').returns(null); + + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it(`should reply with status 400 if etag from request does not match the etag on s3`, async () => { + s3.verifyEtag.restore(); + sinon.stub(s3, 'verifyEtag').returns(false); + + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it('should reply with status 400 if the file does not exist on s3', async () => { + s3.verifyEtag.restore(); + sinon.stub(s3, 'verifyEtag').throws('403: UnknownError'); + + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it('should reply with status 500 if failed to add new certificate record to database', async () => { + stubAddCertificate.throws('DatabaseError'); + + const req = createPutReq(); + + await trainingCertificateRoute.confirmUpload(req, res); + + expect(res.statusCode).to.equal(500); + }); + }); + }); + + describe('getPresignedUrlForCertificateDownload', () => { + const mockSignedUrl = 'http://localhost/mock-download-url'; + let res; + let mockFileUid; + let mockFileName; + + beforeEach(() => { + getSignedUrlForDownloadSpy = sinon.stub(s3, 'getSignedUrlForDownload').returns(mockSignedUrl); + mockFileUid = 'mockFileUid'; + mockFileName = 'mockFileName'; + req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/training/${training.uid}/certificate/download`, + body: { filesToDownload: [{ uid: mockFileUid, filename: mockFileName }] }, + establishmentId: user.establishment.uid, + params: { id: user.establishment.uid, workerId: user.uid, trainingUid: training.uid }, + }); + res = httpMocks.createResponse(); + }); + + it('should reply with a status of 200', async () => { + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should return an array with signed url for download and file name in response', async () => { + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + const actual = await res._getJSONData(); + + expect(actual.files).to.deep.equal([{ signedUrl: mockSignedUrl, filename: mockFileName }]); + }); + + it('should call getSignedUrlForDownload with bucket name from config', async () => { + const bucketName = config.get('workerCertificate.bucketname'); + + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + expect(getSignedUrlForDownloadSpy.args[0][0].bucket).to.equal(bucketName); + }); + + it('should call getSignedUrlForDownload with key of formatted uids passed in params', async () => { + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + + const expectedKey = `${req.params.id}/${req.params.workerId}/trainingCertificate/${req.params.trainingUid}/${mockFileUid}`; + expect(getSignedUrlForDownloadSpy.args[0][0].key).to.equal(expectedKey); + }); + + it('should return 400 status and no files message if no file uids in req body', async () => { + req.body = { filesToDownload: [] }; + + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('No files provided in request body'); + expect(getSignedUrlForDownloadSpy).not.to.be.called; + }); + + it('should return 400 status and no files message if no req body', async () => { + req.body = {}; + + await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('No files provided in request body'); + expect(getSignedUrlForDownloadSpy).not.to.be.called; + }); + }); + + describe('delete certificates', () => { + let res; + let stubDeleteCertificatesFromS3; + let stubDeleteCertificate; + let errorMessage; + let mockFileUid1; + let mockFileUid2; + let mockFileUid3; + + let mockKey1; + let mockKey2; + let mockKey3; + + beforeEach(() => { + mockFileUid1 = 'mockFileUid1'; + mockFileUid2 = 'mockFileUid2'; + mockFileUid3 = 'mockFileUid3'; + + mockKey1 = `${user.establishment.uid}/${user.uid}/trainingCertificate/${training.uid}/${mockFileUid1}`; + mockKey2 = `${user.establishment.uid}/${user.uid}/trainingCertificate/${training.uid}/${mockFileUid2}`; + mockKey3 = `${user.establishment.uid}/${user.uid}/trainingCertificate/${training.uid}/${mockFileUid3}`; + req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/training/${training.uid}/certificate/delete`, + body: { filesToDelete: [{ uid: mockFileUid1, filename: 'mockFileName1' }] }, + establishmentId: user.establishment.uid, + params: { id: user.establishment.uid, workerId: user.uid, trainingUid: training.uid }, + }); + res = httpMocks.createResponse(); + errorMessage = 'DatabaseError'; + stubDeleteCertificatesFromS3 = sinon.stub(s3, 'deleteCertificatesFromS3'); + stubDeleteCertificate = sinon.stub(models.trainingCertificates, 'deleteCertificate'); + stubCountCertificatesToBeDeleted = sinon.stub(models.trainingCertificates, 'countCertificatesToBeDeleted'); + }); + + it('should return a 200 status when files successfully deleted', async () => { + stubDeleteCertificate.returns(1); + stubDeleteCertificatesFromS3.returns({ Deleted: [{ Key: mockKey1 }] }); + stubCountCertificatesToBeDeleted.returns(1); + + await trainingCertificateRoute.deleteCertificates(req, res); + + expect(res.statusCode).to.equal(200); + }); + + describe('errors', () => { + it('should return 400 status and message if no files in req body', async () => { + req.body = {}; + + await trainingCertificateRoute.deleteCertificates(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('No files provided in request body'); + }); + + it('should return 500 if there was a database error when calling countCertificatesToBeDeleted', async () => { + req.body = { + filesToDelete: [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ], + }; + stubCountCertificatesToBeDeleted.throws(errorMessage); + + await trainingCertificateRoute.deleteCertificates(req, res); + + expect(res.statusCode).to.equal(500); + }); + + it('should return 500 if there was a database error on DB deleteCertificate call', async () => { + req.body = { + filesToDelete: [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ], + }; + stubCountCertificatesToBeDeleted.returns(3); + stubDeleteCertificate.throws(errorMessage); + + await trainingCertificateRoute.deleteCertificates(req, res); + + expect(res.statusCode).to.equal(500); + }); + + it('should return 400 status code if the number of records in database does not match request', async () => { + req.body = { + filesToDelete: [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ], + }; + + stubCountCertificatesToBeDeleted.returns(3); + stubCountCertificatesToBeDeleted.returns(0); + + await trainingCertificateRoute.deleteCertificates(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Invalid request'); + }); + }); + }); +}); diff --git a/frontend/src/app/core/model/training.model.ts b/frontend/src/app/core/model/training.model.ts index 7e801da6d3..855039d5ef 100644 --- a/frontend/src/app/core/model/training.model.ts +++ b/frontend/src/app/core/model/training.model.ts @@ -24,18 +24,41 @@ export interface TrainingRecordRequest { notes?: string; } +export interface CreateTrainingRecordResponse extends TrainingRecordRequest { + uid: string; + workerUid: string; + created: string; +} + export interface TrainingResponse { count: number; lastUpdated?: string; training: TrainingRecord[]; } +export interface CertificateDownload { + uid: string; + filename: string; +} + +export interface CertificateUpload { + files: File[]; + trainingRecord: TrainingRecord; +} + +export interface TrainingCertificate { + uid: string; + filename: string; + uploadDate: string; +} + export interface TrainingRecord { accredited?: boolean; trainingCategory: { id: number; category: string; }; + trainingCertificates: TrainingCertificate[]; completed?: Date; created: Date; expires?: Date; @@ -109,3 +132,29 @@ export interface TrainingRecordCategories { training: Training[]; isMandatory: boolean; } + +export interface UploadCertificateSignedUrlRequest { + files: { filename: string }[]; +} + +export interface UploadCertificateSignedUrlResponse { + files: { filename: string; fileId: string; signedUrl: string; key: string }[]; +} + +export interface DownloadCertificateSignedUrlResponse { + files: { filename: string; signedUrl: string }[]; +} + +export interface S3UploadResponse { + headers: { etag: string }; +} +export interface FileInfoWithETag { + filename: string; + fileId: string; + etag: string; + key: string; +} + +export interface ConfirmUploadRequest { + files: { filename: string; fileId: string; etag: string }[]; +} diff --git a/frontend/src/app/core/services/training.service.spec.ts b/frontend/src/app/core/services/training.service.spec.ts index 630c374d4e..9a58115e2e 100644 --- a/frontend/src/app/core/services/training.service.spec.ts +++ b/frontend/src/app/core/services/training.service.spec.ts @@ -1,8 +1,8 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; - -import { TrainingService } from './training.service'; import { environment } from 'src/environments/environment'; +import { TrainingService } from './training.service'; +import { TrainingCertificate } from '@core/model/training.model'; describe('TrainingService', () => { let service: TrainingService; @@ -100,4 +100,204 @@ describe('TrainingService', () => { expect(service.getUpdatingSelectedStaffForMultipleTraining()).toBe(null); }); }); + + describe('addCertificateToTraining', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockTrainingUid = 'mockTrainingUid'; + + const mockUploadFiles = [new File([''], 'certificate.pdf')]; + const mockFileId = 'mockFileId'; + const mockEtagFromS3 = 'mock-etag'; + const mockSignedUrl = 'http://localhost/mock-signed-url-for-upload'; + + const certificateEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate`; + + it('should call to backend to retreive a signed url to upload certificate', async () => { + service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + expect(signedUrlRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains filename', async () => { + service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + + const expectedRequestBody = { files: [{ filename: 'certificate.pdf' }] }; + + expect(signedUrlRequest.request.body).toEqual(expectedRequestBody); + }); + + it('should upload the file with the signed url received from backend', async () => { + service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[0]); + }); + + it('should call to backend to confirm upload complete', async () => { + const mockKey = 'abcd/adsadsadvfdv/123dsvf'; + service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId, key: mockKey }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + uploadToS3Request.flush(null, { headers: { etag: mockEtagFromS3 } }); + + const confirmUploadRequest = http.expectOne(certificateEndpoint); + const expectedconfirmUploadReqBody = { + files: [{ filename: mockUploadFiles[0].name, fileId: mockFileId, etag: mockEtagFromS3, key: mockKey }], + }; + + expect(confirmUploadRequest.request.method).toBe('PUT'); + expect(confirmUploadRequest.request.body).toEqual(expectedconfirmUploadReqBody); + }); + + describe('multiple files upload', () => { + const mockUploadFilenames = ['certificate1.pdf', 'certificate2.pdf', 'certificate3.pdf']; + const mockUploadFiles = mockUploadFilenames.map((filename) => new File([''], filename)); + const mockFileIds = ['fileId1', 'fileId2', 'fileId3']; + const mockEtags = ['etag1', 'etag2', 'etag3']; + const mockSignedUrls = mockFileIds.map((fileId) => `${mockSignedUrl}/${fileId}`); + const mockKeys = mockFileIds.map((fileId) => `${fileId}/mockKey`); + + const mockSignedUrlResponse = { + files: mockUploadFilenames.map((filename, index) => ({ + filename, + signedUrl: mockSignedUrls[index], + fileId: mockFileIds[index], + key: mockKeys[index], + })), + }; + + const expectedSignedUrlReqBody = { + files: [ + { filename: mockUploadFiles[0].name }, + { filename: mockUploadFiles[1].name }, + { filename: mockUploadFiles[2].name }, + ], + }; + const expectedConfirmUploadReqBody = { + files: [ + { filename: mockUploadFiles[0].name, fileId: mockFileIds[0], etag: mockEtags[0], key: mockKeys[0] }, + { filename: mockUploadFiles[1].name, fileId: mockFileIds[1], etag: mockEtags[1], key: mockKeys[1] }, + { filename: mockUploadFiles[2].name, fileId: mockFileIds[2], etag: mockEtags[2], key: mockKeys[2] }, + ], + }; + + it('should be able to upload multiple files at the same time', () => { + service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne({ method: 'POST', url: certificateEndpoint }); + expect(signedUrlRequest.request.body).toEqual(expectedSignedUrlReqBody); + + signedUrlRequest.flush(mockSignedUrlResponse); + + mockSignedUrls.forEach((signedUrl, index) => { + const uploadToS3Request = http.expectOne(signedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[index]); + + uploadToS3Request.flush(null, { headers: { etag: mockEtags[index] } }); + }); + + const confirmUploadRequest = http.expectOne({ method: 'PUT', url: certificateEndpoint }); + expect(confirmUploadRequest.request.body).toEqual(expectedConfirmUploadReqBody); + }); + }); + }); + + describe('downloadCertificates', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockTrainingUid = 'mockTrainingUid'; + + const mockFiles = [{ uid: 'mockCertificateUid123', filename: 'mockCertificateName' }]; + + const certificateDownloadEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/download`; + + it('should make call to expected backend endpoint', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + expect(downloadRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains file uid', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + const expectedRequestBody = { filesToDownload: mockFiles }; + + expect(downloadRequest.request.body).toEqual(expectedRequestBody); + }); + }); + + describe('triggerCertificateDownloads', () => { + it('should download certificates by creating and triggering anchor tag, then cleaning DOM', () => { + const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; + const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); + const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); + const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); + const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); + spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); + + service.triggerCertificateDownloads(mockCertificates).subscribe(); + + const downloadReq = http.expectOne(mockCertificates[0].signedUrl); + downloadReq.flush(mockBlob); + + // Assert anchor element appended + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalled(); + + // Assert anchor element has correct attributes + const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; + expect(createdAnchor.href).toBe(mockBlobUrl); + expect(createdAnchor.download).toBe(mockCertificates[0].filename); + + // Assert DOM is cleaned up after download + expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + describe('deleteCertificates', () => { + it('should call the endpoint for deleting training certificates', async () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockTrainingUid = 'mockTrainingUid'; + const mockFilesToDelete = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-09-23T11:02:10.000Z', + }, + ]; + + const deleteCertificatesEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/delete`; + + service.deleteCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFilesToDelete).subscribe(); + + const deleteRequest = http.expectOne(deleteCertificatesEndpoint); + const expectedRequestBody = { filesToDelete: mockFilesToDelete }; + + expect(deleteRequest.request.method).toBe('POST'); + expect(deleteRequest.request.body).toEqual(expectedRequestBody); + }); + }); + }); }); diff --git a/frontend/src/app/core/services/training.service.ts b/frontend/src/app/core/services/training.service.ts index 543d49e0c9..db468023b8 100644 --- a/frontend/src/app/core/services/training.service.ts +++ b/frontend/src/app/core/services/training.service.ts @@ -3,13 +3,20 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { allMandatoryTrainingCategories, - TrainingCategory, - TrainingCategoryResponse, + CertificateDownload, + UploadCertificateSignedUrlRequest, + UploadCertificateSignedUrlResponse, + ConfirmUploadRequest, + FileInfoWithETag, + S3UploadResponse, SelectedTraining, + TrainingCategory, + DownloadCertificateSignedUrlResponse, + TrainingCertificate, } from '@core/model/training.model'; import { Worker } from '@core/model/worker.model'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, from, Observable } from 'rxjs'; +import { map, mergeAll, mergeMap, tap } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; @Injectable({ @@ -130,4 +137,140 @@ export class TrainingService { public clearUpdatingSelectedStaffForMultipleTraining(): void { this.updatingSelectedStaffForMultipleTraining = null; } + + public addCertificateToTraining(workplaceUid: string, workerUid: string, trainingUid: string, filesToUpload: File[]) { + const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); + const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; + + return this.http + .post( + `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, + requestBody, + ) + .pipe( + mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), + map((allFileInfoWithETag) => this.buildConfirmUploadRequestBody(allFileInfoWithETag)), + mergeMap((confirmUploadRequestBody) => + this.confirmCertificateUpload(workplaceUid, workerUid, trainingUid, confirmUploadRequestBody), + ), + ); + } + + private uploadAllCertificatestoS3( + signedUrlResponse: UploadCertificateSignedUrlResponse, + filesToUpload: File[], + ): Observable { + const allUploadResults$ = signedUrlResponse.files.map(({ signedUrl, fileId, filename, key }, index) => { + const fileToUpload = filesToUpload[index]; + if (!fileToUpload.name || fileToUpload.name !== filename) { + throw new Error('Invalid response from backend'); + } + return this.uploadOneCertificateToS3(signedUrl, fileId, fileToUpload, key); + }); + + return forkJoin(allUploadResults$); + } + + private uploadOneCertificateToS3( + signedUrl: string, + fileId: string, + uploadFile: File, + key: string, + ): Observable { + return this.http.put(signedUrl, uploadFile, { observe: 'response' }).pipe( + map((s3response) => ({ + etag: s3response?.headers?.get('etag'), + fileId, + filename: uploadFile.name, + key, + })), + ); + } + + public downloadCertificates( + workplaceUid: string, + workerUid: string, + trainingUid: string, + filesToDownload: CertificateDownload[], + ): Observable { + return this.getCertificateDownloadUrls(workplaceUid, workerUid, trainingUid, filesToDownload).pipe( + mergeMap((res) => this.triggerCertificateDownloads(res['files'])), + ); + } + + public getCertificateDownloadUrls( + workplaceUid: string, + workerUid: string, + trainingUid: string, + filesToDownload: CertificateDownload[], + ) { + return this.http.post( + `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/download`, + { filesToDownload }, + ); + } + + public triggerCertificateDownloads(files: { signedUrl: string; filename: string }[]): Observable<{ + blob: Blob; + filename: string; + }> { + const downloadedBlobs = files.map((file) => this.http.get(file.signedUrl, { responseType: 'blob' })); + const blobsAndFilenames = downloadedBlobs.map((blob$, index) => + blob$.pipe(map((blob) => ({ blob, filename: files[index].filename }))), + ); + return from(blobsAndFilenames).pipe( + mergeAll(), + tap(({ blob, filename }) => this.triggerSingleCertificateDownload(blob, filename)), + ); + } + + private triggerSingleCertificateDownload(fileBlob: Blob, filename: string): void { + const blobUrl = window.URL.createObjectURL(fileBlob); + const link = this.createHiddenDownloadLink(blobUrl, filename); + + // Append the link to the body and click to trigger download + document.body.appendChild(link); + link.click(); + + // Remove the link + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + } + + private createHiddenDownloadLink(blobUrl: string, filename: string): HTMLAnchorElement { + const link = document.createElement('a'); + + link.href = blobUrl; + link.download = filename; + link.style.display = 'none'; + return link; + } + + private buildConfirmUploadRequestBody(allFileInfoWithETag: FileInfoWithETag[]): ConfirmUploadRequest { + return { files: allFileInfoWithETag }; + } + + private confirmCertificateUpload( + workplaceUid: string, + workerUid: string, + trainingUid: string, + confirmUploadRequestBody: ConfirmUploadRequest, + ) { + return this.http.put( + `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, + confirmUploadRequestBody, + ); + } + + public deleteCertificates( + workplaceUid: string, + workerUid: string, + trainingUid: string, + filesToDelete: TrainingCertificate[], + ): Observable { + return this.http.post( + `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/delete`, + { filesToDelete }, + ); + } } diff --git a/frontend/src/app/core/services/worker.service.ts b/frontend/src/app/core/services/worker.service.ts index dd2b3cc150..79c67bd49e 100644 --- a/frontend/src/app/core/services/worker.service.ts +++ b/frontend/src/app/core/services/worker.service.ts @@ -10,7 +10,12 @@ import { QualificationsResponse, QualificationType, } from '@core/model/qualification.model'; -import { MultipleTrainingResponse, TrainingRecordRequest, TrainingResponse } from '@core/model/training.model'; +import { + CreateTrainingRecordResponse, + MultipleTrainingResponse, + TrainingRecordRequest, + TrainingResponse, +} from '@core/model/training.model'; import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; import { URLStructure } from '@core/model/url.model'; import { Worker, WorkerEditResponse, WorkersResponse } from '@core/model/worker.model'; @@ -247,7 +252,7 @@ export class WorkerService { } createTrainingRecord(workplaceUid: string, workerId: string, record: TrainingRecordRequest) { - return this.http.post( + return this.http.post( `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerId}/training`, record, ); @@ -259,7 +264,7 @@ export class WorkerService { trainingRecordId: string, record: TrainingRecordRequest, ) { - return this.http.put( + return this.http.put( `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerId}/training/${trainingRecordId}`, record, ); diff --git a/frontend/src/app/core/test-utils/MockWorkerService.ts b/frontend/src/app/core/test-utils/MockWorkerService.ts index 6ecc5110b7..7acc8f6aaf 100644 --- a/frontend/src/app/core/test-utils/MockWorkerService.ts +++ b/frontend/src/app/core/test-utils/MockWorkerService.ts @@ -2,13 +2,14 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { QualificationsByGroup, QualificationType } from '@core/model/qualification.model'; -import { MultipleTrainingResponse, TrainingRecordRequest } from '@core/model/training.model'; +import { CreateTrainingRecordResponse, MultipleTrainingResponse, TrainingRecordRequest } from '@core/model/training.model'; import { URLStructure } from '@core/model/url.model'; import { Worker, WorkerEditResponse, WorkersResponse } from '@core/model/worker.model'; import { NewWorkerMandatoryInfo, WorkerService } from '@core/services/worker.service'; import { build, fake, oneOf, perBuild, sequence } from '@jackfranklin/test-data-bot'; import { Observable, of } from 'rxjs'; -import { AvailableQualificationsResponse, Qualification } from '../model/qualification.model'; + +import { AvailableQualificationsResponse } from '../model/qualification.model'; export const workerBuilder = build('Worker', { fields: { @@ -461,7 +462,7 @@ export class MockWorkerService extends WorkerService { workplaceUid: string, workerId: string, record: TrainingRecordRequest, - ): Observable { + ): Observable { return of(trainingRecord); } diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts index 5d5762b851..fcbce6f949 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts @@ -5,20 +5,23 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { AlertService } from '@core/services/alert.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; +import { MockTrainingCategoryService, trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; import { MockTrainingService } from '@core/test-utils/MockTrainingService'; +import { trainingRecord } from '@core/test-utils/MockWorkerService'; import { MockWorkerServiceWithWorker } from '@core/test-utils/MockWorkerServiceWithWorker'; +import { CertificationsTableComponent } from '@shared/components/certifications-table/certifications-table.component'; import { SharedModule } from '@shared/shared.module'; import { fireEvent, render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import sinon from 'sinon'; +import { SelectUploadFileComponent } from '../../../shared/components/select-upload-file/select-upload-file.component'; import { AddEditTrainingComponent } from './add-edit-training.component'; -import { MockTrainingCategoryService, trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; -import { TrainingCategoryService } from '@core/services/training-category.service'; describe('AddEditTrainingComponent', () => { async function setup(trainingRecordId = '1', qsParamGetMock = sinon.fake()) { @@ -26,6 +29,7 @@ describe('AddEditTrainingComponent', () => { AddEditTrainingComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], + declarations: [CertificationsTableComponent, SelectUploadFileComponent], providers: [ WindowRef, { @@ -60,7 +64,7 @@ describe('AddEditTrainingComponent', () => { const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); const workerService = injector.inject(WorkerService); - const updateSpy = spyOn(workerService, 'updateTrainingRecord').and.callThrough(); + const updateSpy = spyOn(workerService, 'updateTrainingRecord').and.returnValue(of(null)); const createSpy = spyOn(workerService, 'createTrainingRecord').and.callThrough(); const component = fixture.componentInstance; @@ -179,6 +183,30 @@ describe('AddEditTrainingComponent', () => { }); }); + describe('Notes section', () => { + it('should have the notes section closed on page load', async () => { + const { getByText, getByTestId } = await setup(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Open notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).toContain('govuk-visually-hidden'); + }); + + it('should display the notes section after clicking Open notes', async () => { + const { fixture, getByText, getByTestId } = await setup(); + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + + fixture.detectChanges(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + }); + }); + describe('fillForm', () => { it('should prefill the form if there is a training record id and there is a training record', async () => { const { component, workerService } = await setup(); @@ -198,6 +226,40 @@ describe('AddEditTrainingComponent', () => { expect(workerServiceSpy).toHaveBeenCalledWith(workplace.uid, worker.uid, trainingRecordId); }); + it('should open the notes section if there are some notes in record', async () => { + const mockTrainingWithNotes = { + trainingCategory: { id: 1, category: 'Communication' }, + notes: 'some notes about this training', + }; + const { component, fixture, workerService, getByTestId, getByText } = await setup(); + + spyOn(workerService, 'getTrainingRecord').and.returnValue(of(mockTrainingWithNotes)); + component.ngOnInit(); + fixture.detectChanges(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + const notesTextArea = within(notesSection).getByRole('textbox', { name: 'Add a note' }) as HTMLTextAreaElement; + expect(notesTextArea.value).toEqual('some notes about this training'); + }); + + it('should display the remaining character count correctly if there are some notes in record', async () => { + const mockTrainingWithNotes = { + trainingCategory: { id: 1, category: 'Communication' }, + notes: 'some notes about this training', + }; + const { component, fixture, workerService, getByText } = await setup(); + + spyOn(workerService, 'getTrainingRecord').and.returnValue(of(mockTrainingWithNotes)); + component.ngOnInit(); + fixture.detectChanges(); + + const expectedRemainingCharCounts = component.notesMaxLength - 'some notes about this training'.length; + expect(getByText(`You have ${expectedRemainingCharCounts} characters remaining`)).toBeTruthy; + }); + it('should not prefill the form if there is a training record id but there is no training record', async () => { const { component, workerService } = await setup(); spyOn(workerService, 'getTrainingRecord').and.returnValue(of(null)); @@ -290,14 +352,71 @@ describe('AddEditTrainingComponent', () => { }); }); + describe('Upload file button', () => { + it('should render a file input element', async () => { + const { getByTestId } = await setup(null); + + const uploadSection = getByTestId('uploadCertificate'); + expect(uploadSection).toBeTruthy(); + + const fileInput = within(uploadSection).getByTestId('fileInput'); + expect(fileInput).toBeTruthy(); + }); + + it('should render "No file chosen" beside the file input', async () => { + const { getByTestId } = await setup(null); + + const uploadSection = getByTestId('uploadCertificate'); + + const text = within(uploadSection).getByText('No file chosen'); + expect(text).toBeTruthy(); + }); + + it('should not render "No file chosen" when a file is chosen', async () => { + const { fixture, getByTestId } = await setup(null); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + fixture.detectChanges(); + + const text = within(uploadSection).queryByText('No file chosen'); + expect(text).toBeFalsy(); + }); + + it('should provide aria description to screen reader users', async () => { + const { fixture, getByTestId } = await setup(null); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + let uploadButton = within(uploadSection).getByRole('button', { + description: /The certificate must be a PDF file that's no larger than 500KB/, + }); + expect(uploadButton).toBeTruthy(); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + uploadButton = within(uploadSection).getByRole('button', { + description: '1 file chosen', + }); + expect(uploadButton).toBeTruthy(); + }); + }); + describe('submitting form', () => { it('should call the updateTrainingRecord function if editing existing training, and navigate away from page', async () => { const { component, fixture, getByText, getByLabelText, updateSpy, routerSpy, alertServiceSpy } = await setup(); component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); fixture.detectChanges(); - userEvent.type(getByLabelText('Notes'), 'Some notes added to this training'); + userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); fireEvent.click(getByText('Save and return')); fixture.detectChanges(); @@ -340,6 +459,8 @@ describe('AddEditTrainingComponent', () => { await setup(null); component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); fixture.detectChanges(); component.trainingCategory = { @@ -357,7 +478,7 @@ describe('AddEditTrainingComponent', () => { userEvent.type(within(expiresDate).getByLabelText('Day'), '10'); userEvent.type(within(expiresDate).getByLabelText('Month'), '4'); userEvent.type(within(expiresDate).getByLabelText('Year'), '2022'); - userEvent.type(getByLabelText('Notes'), 'Some notes for this training'); + userEvent.type(getByLabelText('Add a note'), 'Some notes for this training'); fireEvent.click(getByText('Save record')); fixture.detectChanges(); @@ -417,6 +538,161 @@ describe('AddEditTrainingComponent', () => { expect(trainingService.selectedTraining.trainingCategory).toBeNull(); }); + + it('should disable the submit button to prevent it being triggered more than once', async () => { + const { component, fixture, getByText, getByLabelText, trainingService, createSpy } = await setup(null); + + trainingService.setSelectedTrainingCategory({ + id: 2, + seq: 20, + category: 'Autism', + trainingCategoryGroup: 'Specific conditions and disabilities', + }); + component.ngOnInit(); + + userEvent.type(getByLabelText('Training name'), 'Some training'); + userEvent.click(getByLabelText('No')); + + const submitButton = getByText('Save record') as HTMLButtonElement; + userEvent.click(submitButton); + fixture.detectChanges(); + + expect(submitButton.disabled).toBe(true); + }); + + describe('upload certificate of an existing training', () => { + const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); + + it('should call both `addCertificateToTraining` and `updateTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, updateSpy, routerSpy, trainingService } = + await setup(); + + component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( + of(null), + ); + + userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); + userEvent.upload(getByTestId('fileInput'), mockUploadFile); + fireEvent.click(getByText('Save and return')); + fixture.detectChanges(); + + expect(updateSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.trainingRecordId, + { + trainingCategory: { id: 1 }, + title: 'Communication Training 1', + accredited: 'Yes', + completed: '2020-01-02', + expires: '2021-01-02', + notes: 'Some notes added to this training', + }, + ); + + expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.trainingRecordId, + [mockUploadFile], + ); + + expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); + }); + + it('should not call addCertificateToTraining if no file was selected', async () => { + const { component, fixture, getByText, getByLabelText, trainingService } = await setup(); + + component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + + userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); + fireEvent.click(getByText('Save and return')); + + expect(addCertificateToTrainingSpy).not.toHaveBeenCalled(); + }); + }); + + describe('add a new training record and upload certificate together', async () => { + const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); + + it('should call both `addCertificateToTraining` and `createTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, createSpy, routerSpy, trainingService } = + await setup(null); + + component.previousUrl = ['/goToPreviousUrl']; + component.trainingCategory = { + category: 'Autism', + id: 2, + }; + + fixture.detectChanges(); + + const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( + of(null), + ); + + userEvent.type(getByLabelText('Training name'), 'Understanding Autism'); + userEvent.click(getByLabelText('Yes')); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile); + fireEvent.click(getByText('Save record')); + fixture.detectChanges(); + + expect(createSpy).toHaveBeenCalledWith(component.workplace.uid, component.worker.uid, { + trainingCategory: { id: 2 }, + title: 'Understanding Autism', + accredited: 'Yes', + completed: null, + expires: null, + notes: null, + }); + + expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + trainingRecord.uid, + [mockUploadFile], + ); + + expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); + }); + + it('should not call `addCertificateToTraining` when no upload file was selected', async () => { + const { component, fixture, getByText, getByLabelText, createSpy, routerSpy, trainingService } = await setup( + null, + ); + + component.previousUrl = ['/goToPreviousUrl']; + component.trainingCategory = { + category: 'Autism', + id: 2, + }; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + + userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); + fireEvent.click(getByText('Save record')); + + expect(createSpy).toHaveBeenCalled; + + expect(addCertificateToTrainingSpy).not.toHaveBeenCalled; + + expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); + }); + }); }); describe('errors', () => { @@ -603,22 +879,110 @@ describe('AddEditTrainingComponent', () => { }); describe('notes errors', () => { + const veryLongString = + 'This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string.'; + it('should show an error message if the notes is over 1000 characters', async () => { const { component, fixture, getByText, getByLabelText, getAllByText } = await setup(null); component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); fixture.detectChanges(); - const veryLongString = - 'This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string.'; - - userEvent.type(getByLabelText('Notes'), veryLongString); + userEvent.type(getByLabelText('Add a note'), veryLongString); fireEvent.click(getByText('Save record')); fixture.detectChanges(); expect(getAllByText('Notes must be 1000 characters or fewer').length).toEqual(2); }); + + it('should open the notes section if the notes input is over 1000 characters and section is closed on submit', async () => { + const { fixture, getByText, getByLabelText, getByTestId } = await setup(null); + + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + userEvent.type(getByLabelText('Add a note'), veryLongString); + + const closeNotesButton = getByText('Close notes'); + closeNotesButton.click(); + fixture.detectChanges(); + + fireEvent.click(getByText('Save record')); + fixture.detectChanges(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + }); + }); + + describe('uploadCertificate errors', () => { + it('should show an error message if the selected file is over 500 KB', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'large-file.pdf', { type: 'application/pdf' }); + + Object.defineProperty(mockUploadFile, 'size', { + value: 10 * 1024 * 1024, // 10MB + }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, mockUploadFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be no larger than 500KB')).toBeTruthy(); + }); + + it('should show an error message if the selected file is not a pdf file', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, [mockUploadFile]); + + fixture.detectChanges(); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + }); + + it('should clear the error message when user select a valid file instead', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const invalidFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + const validFile = new File(['some file content'], 'certificate.pdf', { type: 'application/pdf' }); + + const fileInputButton = getByTestId('fileInput'); + userEvent.upload(fileInputButton, [invalidFile]); + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + userEvent.upload(fileInputButton, [validFile]); + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); + + it('should provide aria description to screen reader users when error happen', async () => { + const { fixture, getByTestId } = await setup(null); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'non-pdf-file.csv')); + + const uploadButton = within(uploadSection).getByRole('button', { + description: /Error: The certificate must be a PDF file/, + }); + expect(uploadButton).toBeTruthy(); + }); }); }); @@ -633,4 +997,309 @@ describe('AddEditTrainingComponent', () => { `workplace/${component.establishmentUid}/training-and-qualifications-record/${component.workerId}/add-training`, ]); }); + + describe('training certifications', () => { + it('should show when there are training certifications', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCertificates = [ + { + uid: '396ae33f-a99b-4035-9f29-718529a54244', + filename: 'first_aid.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + fixture.detectChanges(); + + expect(getByTestId('trainingCertificatesTable')).toBeTruthy(); + }); + + it('should not show when there are no training certifications', async () => { + const { component, fixture, queryByTestId } = await setup(); + + component.trainingCertificates = []; + + fixture.detectChanges(); + + expect(queryByTestId('trainingCertificatesTable')).toBeFalsy(); + }); + + describe('Download buttons', () => { + const mockTrainingCertificate = { + uid: '396ae33f-a99b-4035-9f29-718529a54244', + filename: 'first_aid.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }; + + const mockTrainingCertificate2 = { + uid: '315ae33f-a99b-1235-9f29-718529a15044', + filename: 'first_aid_advanced.pdf', + uploadDate: '2024-04-13T16:44:21.121Z', + }; + + it('should show Download button when there is an existing training certificate', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCertificates = [mockTrainingCertificate]; + + fixture.detectChanges(); + + const certificatesTable = getByTestId('trainingCertificatesTable'); + const downloadButton = within(certificatesTable).getByText('Download'); + + expect(downloadButton).toBeTruthy(); + }); + + it('should make call to downloadCertificates with required uids and file uid in array when Download button clicked', async () => { + const { component, fixture, getByTestId, trainingService } = await setup(); + + const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + of({ files: ['abc123'] }), + ); + component.trainingCertificates = [mockTrainingCertificate]; + fixture.detectChanges(); + + const certificatesTable = getByTestId('trainingCertificatesTable'); + const firstCertDownloadButton = within(certificatesTable).getAllByText('Download')[0]; + firstCertDownloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.trainingRecordId, + [{ uid: mockTrainingCertificate.uid, filename: mockTrainingCertificate.filename }], + ); + }); + + it('should make call to downloadCertificates with all certificate file uids in array when Download all button clicked', async () => { + const { component, fixture, getByTestId, trainingService } = await setup(); + + const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + of({ files: ['abc123'] }), + ); + component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; + fixture.detectChanges(); + + const certificatesTable = getByTestId('trainingCertificatesTable'); + const downloadButton = within(certificatesTable).getByText('Download all'); + downloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.trainingRecordId, + [ + { uid: mockTrainingCertificate.uid, filename: mockTrainingCertificate.filename }, + { uid: mockTrainingCertificate2.uid, filename: mockTrainingCertificate2.filename }, + ], + ); + }); + + it('should display error message when Download fails', async () => { + const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + + spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; + + fixture.detectChanges(); + + const certificatesTable = getByTestId('trainingCertificatesTable'); + const downloadButton = within(certificatesTable).getAllByText('Download')[1]; + downloadButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); + + it('should display error message when Download all fails', async () => { + const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + + spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('some download error')); + component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; + + fixture.detectChanges(); + + const certificatesTable = getByTestId('trainingCertificatesTable'); + const downloadAllButton = within(certificatesTable).getByText('Download all'); + downloadAllButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); + }); + + describe('files to be uploaded', () => { + const mockUploadFile1 = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); + const mockUploadFile2 = new File(['some file content'], 'First aid 2024.pdf', { type: 'application/pdf' }); + + it('should add a new upload file to the certification table when a file is selected', async () => { + const { component, fixture, getByTestId } = await setup(); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + + const certificationTable = getByTestId('trainingCertificatesTable'); + + expect(certificationTable).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(component.filesToUpload).toEqual([mockUploadFile1]); + }); + + it('should remove an upload file when its remove button is clicked', async () => { + const { component, fixture, getByTestId, getByText } = await setup(); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + const certificationTable = getByTestId('trainingCertificatesTable'); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile2.name)).toBeTruthy(); + + const rowForFile2 = getByText(mockUploadFile2.name).parentElement; + const removeButtonForFile2 = within(rowForFile2).getByText('Remove'); + + userEvent.click(removeButtonForFile2); + + expect(within(certificationTable).queryByText(mockUploadFile2.name)).toBeFalsy(); + + expect(within(certificationTable).queryByText(mockUploadFile1.name)).toBeTruthy(); + expect(component.filesToUpload).toHaveSize(1); + expect(component.filesToUpload[0]).toEqual(mockUploadFile1); + }); + }); + + describe('saved files to be removed', () => { + it('should remove a file from the table when the remove button is clicked', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCertificates = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'first_aid_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-3', + filename: 'first_aid_v3.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + fixture.detectChanges(); + + const certificateRow2 = getByTestId('certificate-row-2'); + + const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + + fireEvent.click(removeButtonForRow2); + + fixture.detectChanges(); + + expect(component.trainingCertificates.length).toBe(2); + }); + + it('should remove all file from the table when the remove button is clicked for all saved files', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCertificates = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'first_aid_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-3', + filename: 'first_aid_v3.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + fixture.detectChanges(); + + const certificateRow0 = getByTestId('certificate-row-0'); + const certificateRow1 = getByTestId('certificate-row-1'); + const certificateRow2 = getByTestId('certificate-row-2'); + + const removeButtonForRow0 = within(certificateRow0).getByText('Remove'); + const removeButtonForRow1 = within(certificateRow1).getByText('Remove'); + const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + + fireEvent.click(removeButtonForRow0); + fireEvent.click(removeButtonForRow1); + fireEvent.click(removeButtonForRow2); + + fixture.detectChanges(); + expect(component.trainingCertificates.length).toBe(0); + }); + + it('should call the training service when save and return is clicked', async () => { + const { component, fixture, getByTestId, getByText, trainingService } = await setup(); + + component.trainingCertificates = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'first_aid_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + fixture.detectChanges(); + + const certificateRow = getByTestId('certificate-row-0'); + + const removeButtonForRow = within(certificateRow).getByText('Remove'); + const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + fireEvent.click(removeButtonForRow); + fireEvent.click(getByText('Save and return')); + + fixture.detectChanges(); + + expect(trainingServiceSpy).toHaveBeenCalledWith( + component.establishmentUid, + component.workerId, + component.trainingRecordId, + component.filesToRemove, + ); + }); + + it('should not call the training service when save and return is clicked and there are no files to remove ', async () => { + const { component, fixture, getByText, trainingService } = await setup(); + + component.trainingCertificates = []; + + fixture.detectChanges(); + + const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + + fireEvent.click(getByText('Save and return')); + + fixture.detectChanges(); + + expect(trainingServiceSpy).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts index 72f89ada15..df99062d84 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts @@ -1,16 +1,20 @@ +import { HttpClient } from '@angular/common/http'; import { AfterViewInit, Component, OnInit } from '@angular/core'; import { UntypedFormBuilder } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { DATE_PARSE_FORMAT } from '@core/constants/constants'; +import { CertificateDownload, TrainingCertificate } from '@core/model/training.model'; import { AlertService } from '@core/services/alert.service'; import { BackLinkService } from '@core/services/backLink.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WorkerService } from '@core/services/worker.service'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; import dayjs from 'dayjs'; +import { mergeMap } from 'rxjs/operators'; import { AddEditTrainingDirective } from '../../../shared/directives/add-edit-training/add-edit-training.directive'; -import { TrainingCategoryService } from '@core/services/training-category.service'; @Component({ selector: 'app-add-edit-training', @@ -20,6 +24,9 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement public category: string; public establishmentUid: string; public workerId: string; + private _filesToUpload: File[]; + public filesToRemove: TrainingCertificate[] = []; + public certificateErrors: string[] | null; constructor( protected formBuilder: UntypedFormBuilder, @@ -31,6 +38,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement protected trainingCategoryService: TrainingCategoryService, protected workerService: WorkerService, protected alertService: AlertService, + protected http: HttpClient, ) { super( formBuilder, @@ -89,6 +97,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement this.trainingRecord = trainingRecord; this.category = trainingRecord.trainingCategory.category; this.trainingCategory = trainingRecord.trainingCategory; + this.trainingCertificates = trainingRecord.trainingCertificates; const completed = this.trainingRecord.completed ? dayjs(this.trainingRecord.completed, DATE_PARSE_FORMAT) @@ -113,6 +122,10 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement }), notes: this.trainingRecord.notes, }); + if (this.trainingRecord?.notes?.length > 0) { + this.notesOpen = true; + this.remainingCharacterCount = this.notesMaxLength - this.trainingRecord.notes.length; + } } }, (error) => { @@ -122,24 +135,120 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement ); } + public removeSavedFile(fileIndexToRemove: number): void { + let tempTrainingCertificates = this.trainingCertificates.filter( + (certificate, index) => index !== fileIndexToRemove, + ); + + this.filesToRemove.push(this.trainingCertificates[fileIndexToRemove]); + + this.trainingCertificates = tempTrainingCertificates; + } + protected submit(record: any): void { - if (this.trainingRecordId) { - this.subscriptions.add( - this.workerService - .updateTrainingRecord(this.workplace.uid, this.worker.uid, this.trainingRecordId, record) - .subscribe( - () => this.onSuccess(), - (error) => this.onError(error), - ), - ); + this.submitButtonDisabled = true; + let submitTrainingRecord = this.trainingRecordId + ? this.workerService.updateTrainingRecord(this.workplace.uid, this.worker.uid, this.trainingRecordId, record) + : this.workerService.createTrainingRecord(this.workplace.uid, this.worker.uid, record); + + if (this.filesToRemove?.length > 0) { + this.deleteTrainingCertificate(this.filesToRemove); + } + + if (this.filesToUpload?.length > 0) { + submitTrainingRecord = submitTrainingRecord.pipe(mergeMap((response) => this.uploadNewCertificate(response))); + } + + this.subscriptions.add( + submitTrainingRecord.subscribe( + () => this.onSuccess(), + (error) => this.onError(error), + ), + ); + } + + get filesToUpload(): File[] { + return this._filesToUpload ?? []; + } + + private set filesToUpload(files: File[]) { + this._filesToUpload = files ?? []; + } + + private resetUploadFilesError(): void { + this.certificateErrors = null; + } + + public getUploadComponentAriaDescribedBy(): string { + if (this.certificateErrors) { + return 'uploadCertificate-errors uploadCertificate-aria-text'; + } else if (this.filesToUpload?.length > 0) { + return 'uploadCertificate-aria-text'; } else { - this.subscriptions.add( - this.workerService.createTrainingRecord(this.workplace.uid, this.worker.uid, record).subscribe( - () => this.onSuccess(), - (error) => this.onError(error), - ), - ); + return 'uploadCertificate-hint uploadCertificate-aria-text'; + } + } + + public onSelectFiles(newFiles: File[]): void { + this.resetUploadFilesError(); + const errors = CustomValidators.validateUploadCertificates(newFiles); + + if (errors) { + this.certificateErrors = errors; + return; } + + const combinedFiles = [...newFiles, ...this.filesToUpload]; + this.filesToUpload = combinedFiles; + } + + public removeFileToUpload(fileIndexToRemove: number): void { + const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); + this.filesToUpload = filesToKeep; + } + + private uploadNewCertificate(trainingRecordResponse: any) { + const trainingRecordId = this.trainingRecordId ?? trainingRecordResponse.uid; + + return this.trainingService.addCertificateToTraining( + this.workplace.uid, + this.worker.uid, + trainingRecordId, + this.filesToUpload, + ); + } + + public downloadCertificates(fileIndex: number): void { + const filesToDownload = + fileIndex != null + ? [this.formatForCertificateDownload(this.trainingCertificates[fileIndex])] + : this.trainingCertificates.map((certificate) => { + return this.formatForCertificateDownload(certificate); + }); + this.subscriptions.add( + this.trainingService + .downloadCertificates(this.workplace.uid, this.worker.uid, this.trainingRecordId, filesToDownload) + .subscribe( + () => { + this.certificateErrors = []; + }, + (_error) => { + this.certificateErrors = ["There's a problem with this download. Try again later or contact us for help."]; + }, + ), + ); + } + + private formatForCertificateDownload(certificate: TrainingCertificate): CertificateDownload { + return { uid: certificate.uid, filename: certificate.filename }; + } + + private deleteTrainingCertificate(files: TrainingCertificate[]) { + this.subscriptions.add( + this.trainingService + .deleteCertificates(this.establishmentUid, this.workerId, this.trainingRecordId, files) + .subscribe(() => {}), + ); } private onSuccess() { @@ -156,5 +265,6 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement private onError(error) { console.log(error); + this.submitButtonDisabled = false; } } diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.spec.ts index 2772cd4ebc..b7518d9a71 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.spec.ts @@ -5,11 +5,13 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { EstablishmentService } from '@core/services/establishment.service'; +import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; import { MockActivatedRoute } from '@core/test-utils/MockActivatedRoute'; import { MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; +import { MockTrainingCategoryService } from '@core/test-utils/MockTrainingCategoriesService'; import { MockTrainingService, MockTrainingServiceWithPreselectedStaff } from '@core/test-utils/MockTrainingService'; import { MockWorkerServiceWithWorker } from '@core/test-utils/MockWorkerServiceWithWorker'; import { SharedModule } from '@shared/shared.module'; @@ -19,8 +21,6 @@ import sinon from 'sinon'; import { AddMultipleTrainingModule } from '../add-multiple-training.module'; import { MultipleTrainingDetailsComponent } from './training-details.component'; -import { TrainingCategoryService } from '@core/services/training-category.service'; -import { MockTrainingCategoryService } from '@core/test-utils/MockTrainingCategoriesService'; describe('MultipleTrainingDetailsComponent', () => { async function setup( @@ -139,6 +139,8 @@ describe('MultipleTrainingDetailsComponent', () => { id: component.categories[0].id, category: component.categories[0].category, }; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); fixture.detectChanges(); userEvent.type(getByLabelText('Training name'), 'Training'); @@ -151,7 +153,7 @@ describe('MultipleTrainingDetailsComponent', () => { userEvent.type(within(expiryDate).getByLabelText('Day'), '1'); userEvent.type(within(expiryDate).getByLabelText('Month'), '1'); userEvent.type(within(expiryDate).getByLabelText('Year'), '2022'); - userEvent.type(getByLabelText('Notes'), 'Notes for training'); + userEvent.type(getByLabelText('Add a note'), 'Notes for training'); const finishButton = getByText('Continue'); userEvent.click(finishButton); @@ -239,6 +241,58 @@ describe('MultipleTrainingDetailsComponent', () => { }); }); + it('should set the notes section as open if there are some notes', async () => { + const { component, getByText, getByTestId } = await setup(false, true); + + const { notes } = component.trainingService.selectedTraining; + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + const notesTextArea = within(notesSection).getByRole('textbox', { name: 'Add a note' }) as HTMLTextAreaElement; + expect(notesTextArea.value).toEqual(notes); + }); + + it('should display the remaining character count correctly if there are some notes', async () => { + const { component, getByText } = await setup(false, true); + + const { notes } = component.trainingService.selectedTraining; + + const expectedRemainingCharCounts = component.notesMaxLength - notes.length; + expect(getByText(`You have ${expectedRemainingCharCounts} characters remaining`)).toBeTruthy; + }); + + it('should not render certificate upload', async () => { + const { queryByTestId } = await setup(); + const uploadSection = queryByTestId('uploadCertificate'); + expect(uploadSection).toBeFalsy(); + }); + + describe('Notes section', () => { + it('should have the notes section closed on page load', async () => { + const { getByText, getByTestId } = await setup(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Open notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).toContain('govuk-visually-hidden'); + }); + + it('should display the notes section after clicking Open notes', async () => { + const { fixture, getByText, getByTestId } = await setup(); + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + + fixture.detectChanges(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + }); + }); + describe('errors', () => { it('should show an error when training name less than 3 characters', async () => { const { component, getByText, fixture, getAllByText } = await setup(); @@ -343,19 +397,47 @@ describe('MultipleTrainingDetailsComponent', () => { expect(getAllByText('Expiry date must be after date completed').length).toEqual(2); }); - it('should show an error when notes input length is more than 1000 characters', async () => { - const { component, getByText, fixture, getAllByText } = await setup(); - component.form.markAsDirty(); - component.form - .get('notes') - .setValue( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - ); - component.form.get('notes').markAsDirty(); - const finishButton = getByText('Continue'); - fireEvent.click(finishButton); - fixture.detectChanges(); - expect(getAllByText('Notes must be 1000 characters or fewer').length).toEqual(2); + describe('notes errors', () => { + const veryLongString = + 'This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string.'; + + it('should show an error message if the notes is over 1000 characters', async () => { + const { component, fixture, getByText, getByLabelText, getAllByText } = await setup(null); + + component.previousUrl = ['/goToPreviousUrl']; + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + userEvent.type(getByLabelText('Add a note'), veryLongString); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + + expect(getAllByText('Notes must be 1000 characters or fewer').length).toEqual(2); + }); + + it('should open the notes section if the notes input is over 1000 characters and section is closed on submit', async () => { + const { fixture, getByText, getByLabelText, getByTestId } = await setup(null); + + const openNotesButton = getByText('Open notes'); + openNotesButton.click(); + fixture.detectChanges(); + + userEvent.type(getByLabelText('Add a note'), veryLongString); + + const closeNotesButton = getByText('Close notes'); + closeNotesButton.click(); + fixture.detectChanges(); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + + const notesSection = getByTestId('notesSection'); + + expect(getByText('Close notes')).toBeTruthy(); + expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); + }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.ts index f4d6a2c3e3..a391f7410a 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/training-details/training-details.component.ts @@ -92,6 +92,10 @@ export class MultipleTrainingDetailsComponent extends AddEditTrainingDirective i title, category: trainingCategory.id, }); + if (notes?.length > 0) { + this.notesOpen = true; + this.remainingCharacterCount = this.notesMaxLength - notes.length; + } } } diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html index f3e1deeb37..c4f88a7a4e 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html @@ -206,48 +206,35 @@

Training and qualifications< - -
-
- - - - - - -
-
-
+
-
-
-
-
- +
diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts index 1cf5430b7b..549ba9f24d 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts @@ -18,11 +18,15 @@ import { MockPermissionsService } from '@core/test-utils/MockPermissionsService' import { MockWorkerService, qualificationsByGroup } from '@core/test-utils/MockWorkerService'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; import { SharedModule } from '@shared/shared.module'; -import { fireEvent, render } from '@testing-library/angular'; -import { of } from 'rxjs'; +import { fireEvent, render, within } from '@testing-library/angular'; +import { of, throwError } from 'rxjs'; import { WorkersModule } from '../../workers/workers.module'; import { NewTrainingAndQualificationsRecordComponent } from './new-training-and-qualifications-record.component'; +import userEvent from '@testing-library/user-event'; +import { TrainingRecord, TrainingRecords } from '@core/model/training.model'; +import { TrainingService } from '@core/services/training.service'; +import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; describe('NewTrainingAndQualificationsRecordComponent', () => { const workplace = establishmentBuilder() as Establishment; @@ -35,6 +39,77 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { tomorrow.setDate(tomorrow.getDate() + 1); activeDate.setDate(activeDate.getDate() + 93); // 3 months in the future + const mockTrainingData: TrainingRecords = { + lastUpdated: new Date('2020-01-01'), + mandatory: [], + jobRoleMandatoryTrainingCount: [], + nonMandatory: [ + { + category: 'Health', + id: 1, + trainingRecords: [ + { + accredited: true, + completed: new Date('10/20/2021'), + expires: activeDate, + title: 'Health training', + trainingCategory: { id: 1, category: 'Health' }, + trainingCertificates: [], + trainingStatus: 0, + uid: 'someHealthuid', + created: new Date('10/20/2021'), + updated: new Date('10/20/2021'), + updatedBy: '', + }, + ], + }, + { + category: 'Autism', + id: 2, + trainingRecords: [ + { + accredited: true, + completed: new Date('10/20/2021'), + expires: yesterday, + title: 'Autism training', + trainingCategory: { id: 2, category: 'Autism' }, + created: new Date('10/20/2021'), + updated: new Date('10/20/2021'), + updatedBy: '', + trainingCertificates: [ + { + filename: 'test certificate.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + ], + trainingStatus: 3, + uid: 'someAutismuid', + }, + ], + }, + { + category: 'Coshh', + id: 3, + trainingRecords: [ + { + accredited: true, + completed: new Date('01/01/2010'), + expires: tomorrow, + title: 'Coshh training', + trainingCategory: { id: 3, category: 'Coshh' }, + trainingCertificates: [], + trainingStatus: 1, + uid: 'someCoshhuid', + created: new Date('10/20/2021'), + updated: new Date('10/20/2021'), + updatedBy: '', + }, + ], + }, + ], + }; + async function setup( otherJob = false, careCert = true, @@ -74,58 +149,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { careCertificate: careCert ? 'Yes, in progress or partially completed' : null, }, trainingAndQualificationRecords: { - training: { - lastUpdated: new Date('2020-01-01'), - jobRoleMandatoryTraining, - mandatory: mandatoryTraining, - nonMandatory: [ - { - category: 'Health', - id: 1, - trainingRecords: [ - { - accredited: true, - completed: new Date('10/20/2021'), - expires: activeDate, - title: 'Health training', - trainingCategory: { id: 1, category: 'Health' }, - trainingStatus: 0, - uid: 'someHealthuid', - }, - ], - }, - { - category: 'Autism', - id: 2, - trainingRecords: [ - { - accredited: true, - completed: new Date('10/20/2021'), - expires: yesterday, - title: 'Autism training', - trainingCategory: { id: 2, category: 'Autism' }, - trainingStatus: 3, - uid: 'someAutismuid', - }, - ], - }, - { - category: 'Coshh', - id: 3, - trainingRecords: [ - { - accredited: true, - completed: new Date('01/01/2010'), - expires: tomorrow, - title: 'Coshh training', - trainingCategory: { id: 3, category: 'Coshh' }, - trainingStatus: 1, - uid: 'someCoshhuid', - }, - ], - }, - ], - }, + training: { ...mockTrainingData, jobRoleMandatoryTraining, mandatory: mandatoryTraining }, qualifications: noQualifications ? { count: 0, groups: [], lastUpdated: null } : qualificationsByGroup, @@ -307,6 +331,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { spyOn(establishmentService, 'isOwnWorkplace').and.returnValue(isOwnWorkplace); const workerService = injector.inject(WorkerService) as WorkerService; + const trainingService = injector.inject(TrainingService) as TrainingService; + const workerSpy = spyOn(workerService, 'setReturnTo'); workerSpy.and.callThrough(); @@ -325,6 +351,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { routerSpy, workerSpy, workerService, + trainingService, getByText, getAllByText, getByTestId, @@ -402,7 +429,6 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { expect(getByText('Care Certificate:', { exact: false })).toBeTruthy(); expect(getByText('Not answered', { exact: false })).toBeTruthy(); }); - }); describe('Long-Term Absence', async () => { @@ -766,4 +792,189 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { expect(component.getBreadcrumbsJourney()).toBe(JourneyType.ALL_WORKPLACES); }); }); + + describe('certificates', () => { + describe('Download button', () => { + const mockTrainings = [ + { + category: 'HealthWithCertificate', + id: 1, + trainingRecords: [ + { + accredited: true, + completed: new Date('10/20/2021'), + expires: activeDate, + title: 'Health training', + trainingCategory: { id: 1, category: 'HealthWithCertificate' }, + trainingCertificates: [ + { + filename: 'test.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + ], + trainingStatus: 0, + uid: 'someHealthuidWithCertificate', + }, + ] as TrainingRecord[], + }, + ]; + + it('should download a certificate file when download link of a certificate row is clicked', async () => { + const { getByTestId, component, trainingService } = await setup(false, true, mockTrainings); + const uidForTrainingRecord = 'someHealthuidWithCertificate'; + + const trainingRecordRow = getByTestId(uidForTrainingRecord); + const downloadLink = within(trainingRecordRow).getByText('Download'); + + const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue(of(null)); + + userEvent.click(downloadLink); + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + mockTrainings[0].trainingRecords[0].uid, + mockTrainings[0].trainingRecords[0].trainingCertificates, + ); + }); + + it('should call triggerCertificateDownloads with file returned from downloadCertificates', async () => { + const { getByTestId, trainingService } = await setup(false, true, mockTrainings); + const uidForTrainingRecord = 'someHealthuidWithCertificate'; + + const trainingRecordRow = getByTestId(uidForTrainingRecord); + const downloadLink = within(trainingRecordRow).getByText('Download'); + const filesReturnedFromDownloadCertificates = [ + { filename: 'test.pdf', signedUrl: 'signedUrl.com/1872ec19-510d-41de-995d-6abfd3ae888a' }, + ]; + + const triggerCertificateDownloadsSpy = spyOn(trainingService, 'triggerCertificateDownloads').and.returnValue( + of(null), + ); + spyOn(trainingService, 'getCertificateDownloadUrls').and.returnValue( + of({ files: filesReturnedFromDownloadCertificates }), + ); + + userEvent.click(downloadLink); + + expect(triggerCertificateDownloadsSpy).toHaveBeenCalledWith(filesReturnedFromDownloadCertificates); + }); + + it('should display an error message on the training category when certificate download fails', async () => { + const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, mockTrainings); + + const uidForTrainingRecord = 'someHealthuidWithCertificate'; + + const trainingRecordRow = getByTestId(uidForTrainingRecord); + const downloadLink = within(trainingRecordRow).getByText('Download'); + + spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + + userEvent.click(downloadLink); + fixture.detectChanges(); + + expect(getByText("There's a problem with this download. Try again later or contact us for help.")).toBeTruthy(); + }); + }); + + describe('Upload button', () => { + const mockUploadFile = new File(['some file content'], 'certificate.pdf'); + + it('should upload a file when a file is selected from Upload file button', async () => { + const { component, getByTestId, trainingService } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + + const trainingRecordRow = getByTestId('someHealthuid'); + + const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + expect(uploadCertificateSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + 'someHealthuid', + [mockUploadFile], + ); + }); + + it('should show an error message when a non pdf file is selected', async () => { + const invalidFile = new File(['some file content'], 'certificate.csv'); + + const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + + const trainingRecordRow = getByTestId('someHealthuid'); + + const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, invalidFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + expect(uploadCertificateSpy).not.toHaveBeenCalled(); + }); + + it('should show an error message when a file of > 500 KB is selected', async () => { + const invalidFile = new File(['some file content'], 'certificate.pdf'); + Object.defineProperty(invalidFile, 'size', { + value: 600 * 1024, // 600 KB + }); + + const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + + const trainingRecordRow = getByTestId('someHealthuid'); + + const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, invalidFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be no larger than 500KB')).toBeTruthy(); + expect(uploadCertificateSpy).not.toHaveBeenCalled(); + }); + + it('should refresh the training record and display an alert of "Certificate uploaded" on successful upload', async () => { + const { fixture, alertSpy, getByTestId, workerService, trainingService } = await setup(false, true, []); + const mockUpdatedData = { + training: mockTrainingData, + qualifications: { count: 0, groups: [], lastUpdated: null }, + } as TrainingAndQualificationRecords; + const workerSpy = spyOn(workerService, 'getAllTrainingAndQualificationRecords').and.returnValue( + of(mockUpdatedData), + ); + spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + + const trainingRecordRow = getByTestId('someHealthuid'); + const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + await fixture.whenStable(); + + expect(workerSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Certificate uploaded', + }); + }); + + it('should display an error message on the training category when certificate upload fails', async () => { + const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); + spyOn(trainingService, 'addCertificateToTraining').and.returnValue(throwError('failed to upload')); + + const trainingRecordRow = getByTestId('someHealthuid'); + const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + fixture.detectChanges(); + + expect(getByText("There's a problem with this upload. Try again later or contact us for help.")).toBeTruthy(); + }); + }); + }); }); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts.OFF b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts.OFF deleted file mode 100644 index cf002d9468..0000000000 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts.OFF +++ /dev/null @@ -1,754 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { getTestBed, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Establishment } from '@core/model/establishment.model'; -import { AlertService } from '@core/services/alert.service'; -import { BreadcrumbService } from '@core/services/breadcrumb.service'; -import { EstablishmentService } from '@core/services/establishment.service'; -import { PermissionsService } from '@core/services/permissions/permissions.service'; -import { WindowRef } from '@core/services/window.ref'; -import { WorkerService } from '@core/services/worker.service'; -import { MockActivatedRoute } from '@core/test-utils/MockActivatedRoute'; -import { MockBreadcrumbService } from '@core/test-utils/MockBreadcrumbService'; -import { establishmentBuilder, MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; -import { MockPermissionsService } from '@core/test-utils/MockPermissionsService'; -import { MockWorkerService, qualificationsByGroup } from '@core/test-utils/MockWorkerService'; -import { SharedModule } from '@shared/shared.module'; -import { fireEvent, render } from '@testing-library/angular'; -import { of } from 'rxjs'; -import { PdfTrainingAndQualificationService } from '@core/services/pdf-training-and-qualification.service'; - -import { WorkersModule } from '../../workers/workers.module'; -import { NewTrainingAndQualificationsRecordComponent } from './new-training-and-qualifications-record.component'; - -describe('NewTrainingAndQualificationsRecordComponent', () => { - const workplace = establishmentBuilder() as Establishment; - - const yesterday = new Date(); - const tomorrow = new Date(); - const activeDate = new Date(); - - yesterday.setDate(yesterday.getDate() - 1); - tomorrow.setDate(tomorrow.getDate() + 1); - activeDate.setDate(activeDate.getDate() + 93); // 3 months in the future - - async function setup( - otherJob = false, - careCert = true, - mandatoryTraining = [], - jobRoleMandatoryTraining = [], - noQualifications = false, - fragment = 'all-records', - addAlert = false, - ) { - if (addAlert) { - window.history.pushState({ alertMessage: 'Updated record' }, ''); - } - const { fixture, getByText, getAllByText, queryByText, getByTestId } = await render( - NewTrainingAndQualificationsRecordComponent, - { - imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, WorkersModule], - providers: [ - AlertService, - WindowRef, - { - provide: ActivatedRoute, - useValue: new MockActivatedRoute({ - fragment: of(fragment), - parent: { - snapshot: { - data: { - establishment: workplace, - }, - }, - }, - snapshot: { - data: { - worker: { - uid: 123, - nameOrId: 'John', - mainJob: { - title: 'Care Worker', - other: otherJob ? 'Care taker' : undefined, - }, - careCertificate: careCert ? 'Yes, in progress or partially completed' : null, - }, - trainingAndQualificationRecords: { - training: { - lastUpdated: new Date('2020-01-01'), - jobRoleMandatoryTraining, - mandatory: mandatoryTraining, - nonMandatory: [ - { - category: 'Health', - id: 1, - trainingRecords: [ - { - accredited: true, - completed: new Date('10/20/2021'), - expires: activeDate, - title: 'Health training', - trainingCategory: { id: 1, category: 'Health' }, - trainingStatus: 0, - uid: 'someHealthuid', - }, - ], - }, - { - category: 'Autism', - id: 2, - trainingRecords: [ - { - accredited: true, - completed: new Date('10/20/2021'), - expires: yesterday, - title: 'Autism training', - trainingCategory: { id: 2, category: 'Autism' }, - trainingStatus: 3, - uid: 'someAutismuid', - }, - ], - }, - { - category: 'Coshh', - id: 3, - trainingRecords: [ - { - accredited: true, - completed: new Date('01/01/2010'), - expires: tomorrow, - title: 'Coshh training', - trainingCategory: { id: 3, category: 'Coshh' }, - trainingStatus: 1, - uid: 'someCoshhuid', - }, - ], - }, - ], - }, - qualifications: noQualifications - ? { count: 0, groups: [], lastUpdated: null } - : qualificationsByGroup, - }, - expiresSoonAlertDate: { - expiresSoonAlertDate: '90', - }, - mandatoryTrainingCategories: { - allJobRolesCount: 29, - lastUpdated: new Date(), - mandatoryTraining: [ - { - trainingCategoryId: 123, - allJobRoles: false, - category: 'Autism', - selectedJobRoles: true, - jobs: [ - { - id: 15, - title: 'Activities worker, coordinator', - }, - ], - }, - - { - trainingCategoryId: 9, - allJobRoles: false, - category: 'Coshh', - selectedJobRoles: true, - jobs: [ - { - id: 21, - title: 'Other (not directly involved in providing care)', - }, - { - id: 20, - title: 'Other (directly involved in providing care)', - }, - { - id: 29, - title: 'Technician', - }, - { - id: 28, - title: 'Supervisor', - }, - { - id: 27, - title: 'Social worker', - }, - { - id: 26, - title: 'Senior management', - }, - { - id: 25, - title: 'Senior care worker', - }, - { - id: 24, - title: 'Safeguarding and reviewing officer', - }, - { - id: 23, - title: 'Registered Nurse', - }, - { - id: 22, - title: 'Registered Manager', - }, - { - id: 19, - title: 'Occupational therapist assistant', - }, - { - id: 18, - title: 'Occupational therapist', - }, - { - id: 17, - title: 'Nursing associate', - }, - { - id: 16, - title: 'Nursing assistant', - }, - { - id: 15, - title: 'Middle management', - }, - { - id: 14, - title: 'Managers and staff (care-related, but not care-providing)', - }, - { - id: 13, - title: 'First-line manager', - }, - { - id: 12, - title: 'Employment support', - }, - { - id: 11, - title: 'Community, support and outreach work', - }, - { - id: 10, - title: 'Care worker', - }, - { - id: 9, - title: 'Care navigator', - }, - { - id: 8, - title: 'Care coordinator', - }, - { - id: 7, - title: 'Assessment officer', - }, - { - id: 6, - title: `Any children's, young people's job role`, - }, - { - id: 5, - title: 'Ancillary staff (non care-providing)', - }, - { - id: 4, - title: 'Allied health professional (not occupational therapist)', - }, - { - id: 3, - title: 'Advice, guidance and advocacy', - }, - { - id: 2, - title: 'Administrative, office staff (non care-providing)', - }, - { - id: 1, - title: 'Activities worker, coordinator', - }, - ], - }, - ], - mandatoryTrainingCount: 2, - }, - }, - params: { - establishmentuid: '123', - }, - }, - }), - }, - { - provide: WorkerService, - useClass: MockWorkerService, - }, - { provide: EstablishmentService, useClass: MockEstablishmentService }, - { provide: BreadcrumbService, useClass: MockBreadcrumbService }, - { provide: PermissionsService, useClass: MockPermissionsService }, - ], - }, - ); - - const component = fixture.componentInstance; - - const injector = getTestBed(); - const router = injector.inject(Router) as Router; - const routerSpy = spyOn(router, 'navigate'); - routerSpy.and.returnValue(Promise.resolve(true)); - - const workerService = injector.inject(WorkerService) as WorkerService; - const workerSpy = spyOn(workerService, 'setReturnTo'); - workerSpy.and.callThrough(); - - const workplaceUid = component.workplace.uid; - const workerUid = component.worker.uid; - const pdfTrainingAndQualsService = injector.inject( - PdfTrainingAndQualificationService, - ) as PdfTrainingAndQualificationService; - - const alertService = injector.inject(AlertService) as AlertService; - const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); - - return { - component, - fixture, - routerSpy, - workerSpy, - workerService, - getByText, - getAllByText, - getByTestId, - queryByText, - workplaceUid, - workerUid, - alertSpy, - pdfTrainingAndQualsService, - }; - } - - describe('page rendering', async () => { - it('should render a TrainingAndQualificationsRecordComponent', async () => { - const { component } = await setup(); - expect(component).toBeTruthy(); - }); - - it('should display the worker name', async () => { - const { component, getByText } = await setup(); - - expect(getByText(component.worker.nameOrId, { exact: false })).toBeTruthy(); - }); - - it('should display the last updated date in the correct format', async () => { - const { component, getByText, fixture } = await setup(); - - component.lastUpdatedDate = new Date('2020-01-01'); - - fixture.detectChanges(); - - expect(getByText('Last update, 1 January 2020', { exact: false })).toBeTruthy(); - }); - - it('should display the View staff record button', async () => { - const { component, getByText, fixture } = await setup(); - - component.canEditWorker = true; - - fixture.detectChanges(); - - expect(getByText('View staff record', { exact: false })).toBeTruthy(); - }); - - it('should have correct href on the View staff record button', async () => { - const { component, getByText, fixture } = await setup(); - - component.canEditWorker = true; - - fixture.detectChanges(); - - const viewStaffRecordButton = getByText('View staff record', { exact: false }); - - expect(viewStaffRecordButton.getAttribute('href')).toEqual( - `/workplace/${component.workplace.uid}/staff-record/123/staff-record-summary`, - ); - }); - - it('should get the latest update date', async () => { - const { component } = await setup(); - - expect(component.lastUpdatedDate).toEqual(new Date('2020-01-02')); - }); - - it('should show the care certificate value', async () => { - const { getByText } = await setup(); - - expect(getByText('Care Certificate:', { exact: false })).toBeTruthy(); - expect(getByText('Yes, in progress or partially completed', { exact: false })).toBeTruthy(); - }); - - it('should show not answered if no care certificate value', async () => { - const { getByText } = await setup(false, false); - - expect(getByText('Care Certificate:', { exact: false })).toBeTruthy(); - expect(getByText('Not answered', { exact: false })).toBeTruthy(); - }); - - it('should render an alert banner if there is an alert message in state', async () => { - const { component, fixture, alertSpy } = await setup(false, true, [], [], false, 'all-records', true); - component.ngOnInit(); - fixture.detectChanges(); - expect(alertSpy).toHaveBeenCalledWith({ - type: 'success', - message: 'Updated record', - }); - }); - }); - - describe('Long-Term Absence', async () => { - it('should display the Long-Term Absence if the worker is currently flagged as long term absent', async () => { - const { component, fixture, getByText, queryByText } = await setup(); - - component.worker.longTermAbsence = 'Illness'; - fixture.detectChanges(); - - expect(getByText('Long-term absent')).toBeTruthy(); - expect(queryByText('Flag long-term absence')).toBeFalsy(); - }); - - it('should navigate to `/long-term-absence` when pressing the "view" button', async () => { - const { component, routerSpy, fixture, getByTestId } = await setup(); - - component.worker.longTermAbsence = 'Illness'; - fixture.detectChanges(); - - const longTermAbsenceLink = getByTestId('longTermAbsence'); - fireEvent.click(longTermAbsenceLink); - expect(routerSpy).toHaveBeenCalledWith( - ['/workplace', component.workplace.uid, 'training-and-qualifications-record', 123, 'long-term-absence'], - { queryParams: { returnToTrainingAndQuals: 'true' } }, - ); - }); - }); - - describe('Flag long-term absence', async () => { - it('should display the "Flag long-term absence" link if the worker is not currently flagged as long term absent', async () => { - const { component, fixture, getByText } = await setup(); - - component.worker.longTermAbsence = null; - component.canEditWorker = true; - fixture.detectChanges(); - - expect(getByText('Flag long-term absence')).toBeTruthy(); - }); - - it('should navigate to `/long-term-absence` when pressing the "Flag long-term absence" button', async () => { - const { component, fixture, getByTestId, routerSpy } = await setup(); - - component.worker.longTermAbsence = null; - component.canEditWorker = true; - fixture.detectChanges(); - - const flagLongTermAbsenceLink = getByTestId('flagLongTermAbsence'); - fireEvent.click(flagLongTermAbsenceLink); - expect(routerSpy).toHaveBeenCalledWith( - ['/workplace', component.workplace.uid, 'training-and-qualifications-record', 123, 'long-term-absence'], - { queryParams: { returnToTrainingAndQuals: 'true' } }, - ); - }); - }); - - describe('getLastUpdatedDate', async () => { - it('should set last updated date to valid date when null passed in (in case of no qualification records)', async () => { - const { component, getByText, fixture } = await setup(); - - component.getLastUpdatedDate([new Date('2021/01/01'), null]); - fixture.detectChanges(); - - expect(getByText('Last update, 1 January 2021', { exact: false })).toBeTruthy(); - }); - - it('should set last updated date to valid date when null passed in (in case of no training records)', async () => { - const { component, getByText, fixture } = await setup(); - - component.getLastUpdatedDate([null, new Date('2021/05/01')]); - fixture.detectChanges(); - - expect(getByText('Last update, 1 May 2021', { exact: false })).toBeTruthy(); - }); - - it('should set last updated date to null when there is no lastUpdated date for training or quals', async () => { - const { component } = await setup(); - - component.getLastUpdatedDate([null, null]); - - expect(component.lastUpdatedDate).toBe(null); - }); - }); - - describe('records summary', async () => { - it('should render the training and qualifications summary component', async () => { - const { fixture } = await setup(); - expect( - fixture.debugElement.nativeElement.querySelector('app-new-training-and-qualifications-record-summary'), - ).not.toBe(null); - }); - }); - - describe('actions list', async () => { - it('should render the actions list heading', async () => { - const { getByText } = await setup(); - expect(getByText('Actions list')).toBeTruthy(); - }); - - it('should render a actions table with the correct headers', async () => { - const { fixture } = await setup(); - const headerRow = fixture.nativeElement.querySelectorAll('tr')[0]; - expect(headerRow.cells['0'].innerHTML).toBe('Training category'); - expect(headerRow.cells['1'].innerHTML).toBe('Training type'); - expect(headerRow.cells['2'].innerHTML).toBe('Status'); - expect(headerRow.cells['3'].innerHTML).toBe(''); - }); - - it('should render Autism as an expired training with an update link if canEditWorker is true', async () => { - const { component, fixture } = await setup(); - - component.canEditWorker = true; - fixture.detectChanges(); - const actionListTableRows = fixture.nativeElement.querySelectorAll('tr'); - const rowOne = actionListTableRows[1]; - expect(rowOne.cells['0'].innerHTML).toBe('Autism'); - expect(rowOne.cells['1'].innerHTML).toBe('Non-mandatory'); - expect(rowOne.cells['2'].innerHTML).toContain('Expired'); - expect(rowOne.cells['3'].innerHTML).toContain('Update'); - }); - - it('should render Autism as an expired training without an update link if canEditWorker is false', async () => { - const { fixture } = await setup(); - const actionListTableRows = fixture.nativeElement.querySelectorAll('tr'); - const rowOne = actionListTableRows[1]; - expect(rowOne.cells['0'].innerHTML).toBe('Autism'); - expect(rowOne.cells['1'].innerHTML).toBe('Non-mandatory'); - expect(rowOne.cells['2'].innerHTML).toContain('Expired'); - expect(rowOne.cells['3'].innerHTML).not.toContain('Update'); - }); - - it('should render Coshh as an expiring soon training with an update link if canEditWorker is true', async () => { - const { component, fixture } = await setup(); - - component.canEditWorker = true; - fixture.detectChanges(); - const actionListTableRows = fixture.nativeElement.querySelectorAll('tr'); - const rowTwo = actionListTableRows[2]; - expect(rowTwo.cells['0'].innerHTML).toBe('Coshh'); - expect(rowTwo.cells['1'].innerHTML).toBe('Non-mandatory'); - expect(rowTwo.cells['2'].innerHTML).toContain('Expires soon'); - expect(rowTwo.cells['3'].innerHTML).toContain('Update'); - }); - - it('should render Coshh as an expiring soon training without an update link if canEditWorker is false', async () => { - const { fixture } = await setup(); - const actionListTableRows = fixture.nativeElement.querySelectorAll('tr'); - const rowTwo = actionListTableRows[2]; - expect(rowTwo.cells['0'].innerHTML).toBe('Coshh'); - expect(rowTwo.cells['1'].innerHTML).toBe('Non-mandatory'); - expect(rowTwo.cells['2'].innerHTML).toContain('Expires soon'); - expect(rowTwo.cells['3'].innerHTML).not.toContain('Update'); - }); - }); - - describe('tabs', async () => { - it('should render the 4 training and quals tabs', async () => { - const { getByTestId } = await setup(); - expect(getByTestId('allRecordsTab')); - expect(getByTestId('mandatoryTrainingTab')); - expect(getByTestId('nonMandatoryTrainingTab')); - expect(getByTestId('qualificationsTab')); - }); - - describe('all records tab', async () => { - it('should show the all records tab as active when on training record page with fragment all-records', async () => { - const { getByTestId } = await setup(); - - expect(getByTestId('allRecordsTab').getAttribute('class')).toContain('asc-tabs__list-item--active'); - expect(getByTestId('allRecordsTabLink').getAttribute('class')).toContain('asc-tabs__link--active'); - expect(getByTestId('mandatoryTrainingTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('mandatoryTrainingTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('nonMandatoryTrainingTab').getAttribute('class')).not.toContain( - 'asc-tabs__list-item--active', - ); - expect(getByTestId('nonMandatoryTrainingTabLink').getAttribute('class')).not.toContain( - 'asc-tabs__link--active', - ); - expect(getByTestId('qualificationsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('qualificationsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - }); - - it('should render 2 instances of the new-training component and 1 instance of the qualification component when on all record tab', async () => { - const { fixture } = await setup(); - expect(fixture.debugElement.nativeElement.querySelectorAll('app-new-training').length).toBe(2); - expect(fixture.debugElement.nativeElement.querySelector('app-new-qualifications')).not.toBe(null); - }); - - it('should navigate to the training page with fragment all-records when clicking on all-records tab', async () => { - const { getByTestId, routerSpy, component } = await setup(); - const allRecordsTabLink = getByTestId('allRecordsTabLink'); - fireEvent.click(allRecordsTabLink); - expect(routerSpy).toHaveBeenCalledWith( - [ - 'workplace', - component.workplace.uid, - 'training-and-qualifications-record', - component.worker.uid, - 'training', - ], - { fragment: 'all-records' }, - ); - }); - }); - - describe('mandatory training tab', async () => { - it('should show the Mandatory training tab as active when on training record page with fragment mandatory-training', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'mandatory-training'); - - expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('mandatoryTrainingTab').getAttribute('class')).toContain('asc-tabs__list-item--active'); - expect(getByTestId('mandatoryTrainingTabLink').getAttribute('class')).toContain('asc-tabs__link--active'); - expect(getByTestId('nonMandatoryTrainingTab').getAttribute('class')).not.toContain( - 'asc-tabs__list-item--active', - ); - expect(getByTestId('nonMandatoryTrainingTabLink').getAttribute('class')).not.toContain( - 'asc-tabs__link--active', - ); - expect(getByTestId('qualificationsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('qualificationsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - }); - - it('should render 1 instances of the new-training component when on mandatory training tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'mandatory-training'); - - expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); - }); - - it('should navigate to the training page with the fragment mandatory-training when clicking on mandatory training tab', async () => { - const { getByTestId, routerSpy, component } = await setup(); - const mandatoryTrainingTabLink = getByTestId('mandatoryTrainingTabLink'); - fireEvent.click(mandatoryTrainingTabLink); - expect(routerSpy).toHaveBeenCalledWith( - [ - 'workplace', - component.workplace.uid, - 'training-and-qualifications-record', - component.worker.uid, - 'training', - ], - { fragment: 'mandatory-training' }, - ); - }); - }); - - describe('non mandatory training tab', async () => { - it('should show the non Mandatory training tab as active when on training record page with fragment non-mandatory-training', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'non-mandatory-training'); - - expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('mandatoryTrainingTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('mandatoryTrainingTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('nonMandatoryTrainingTab').getAttribute('class')).toContain('asc-tabs__list-item--active'); - expect(getByTestId('nonMandatoryTrainingTabLink').getAttribute('class')).toContain('asc-tabs__link--active'); - expect(getByTestId('qualificationsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('qualificationsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - }); - - it('should render 1 instances of the new-training component when on non mandatory training tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'non-mandatory-training'); - - expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); - }); - - it('should navigate to the training page with the fragment non-mandatory-training when clicking on non mandatory training tab', async () => { - const { getByTestId, routerSpy, component } = await setup(); - const nonMandatoryTrainingTab = getByTestId('nonMandatoryTrainingTabLink'); - fireEvent.click(nonMandatoryTrainingTab); - expect(routerSpy).toHaveBeenCalledWith( - [ - 'workplace', - component.workplace.uid, - 'training-and-qualifications-record', - component.worker.uid, - 'training', - ], - { fragment: 'non-mandatory-training' }, - ); - }); - }); - - describe('qualifications tab', async () => { - it('should show the qualifications tab as active when on training record page with fragment qualifications', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'qualifications'); - - expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('mandatoryTrainingTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); - expect(getByTestId('mandatoryTrainingTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); - expect(getByTestId('nonMandatoryTrainingTab').getAttribute('class')).not.toContain( - 'asc-tabs__list-item--active', - ); - expect(getByTestId('nonMandatoryTrainingTabLink').getAttribute('class')).not.toContain( - 'asc-tabs__link--active', - ); - expect(getByTestId('qualificationsTab').getAttribute('class')).toContain('asc-tabs__list-item--active'); - expect(getByTestId('qualificationsTabLink').getAttribute('class')).toContain('asc-tabs__link--active'); - }); - - it('should render 1 instances of the new-qualifications component when on qualification tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'non-mandatory-training'); - - expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); - }); - - it('should navigate to the training page with the fragment qualifications when clicking on qualification tab', async () => { - const { getByTestId, routerSpy, component } = await setup(); - const qualificationTabLink = getByTestId('qualificationsTabLink'); - fireEvent.click(qualificationTabLink); - expect(routerSpy).toHaveBeenCalledWith( - [ - 'workplace', - component.workplace.uid, - 'training-and-qualifications-record', - component.worker.uid, - 'training', - ], - { fragment: 'qualifications' }, - ); - }); - }); - }); - - describe('BuildTrainingAndQualsPdf', async () => { - it('should download the page as a pdf when the the download as pdf link is clicked', async () => { - const { component, getByText, pdfTrainingAndQualsService, fixture } = await setup(); - const downloadFunctionSpy = spyOn(component, 'downloadAsPDF').and.callThrough(); - const pdfTrainingAndQualsServiceSpy = spyOn( - pdfTrainingAndQualsService, - 'BuildTrainingAndQualsPdf', - ).and.callThrough(); - - component.pdfCount = 1; - - fixture.detectChanges(); - - fireEvent.click(getByText('Download training and qualifications', { exact: false })); - - expect(downloadFunctionSpy).toHaveBeenCalled(); - expect(pdfTrainingAndQualsServiceSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts index 9ef119fef5..71bb6cf0d4 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts @@ -3,7 +3,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { JourneyType } from '@core/breadcrumb/breadcrumb.model'; import { Establishment, mandatoryTraining } from '@core/model/establishment.model'; import { QualificationsByGroup } from '@core/model/qualification.model'; -import { TrainingRecordCategory } from '@core/model/training.model'; +import { CertificateUpload, TrainingRecord, TrainingRecordCategory } from '@core/model/training.model'; +import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; import { Worker } from '@core/model/worker.model'; import { AlertService } from '@core/services/alert.service'; import { BreadcrumbService } from '@core/services/breadcrumb.service'; @@ -16,6 +17,8 @@ import { WorkerService } from '@core/services/worker.service'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; import { Subscription } from 'rxjs'; +import { CustomValidators } from '../../../shared/validators/custom-form-validators'; + @Component({ selector: 'app-new-training-and-qualifications-record', templateUrl: './new-training-and-qualifications-record.component.html', @@ -35,6 +38,7 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe public nonMandatoryTrainingCount: number; public nonMandatoryTraining: TrainingRecordCategory[]; public mandatoryTraining: TrainingRecordCategory[]; + public missingMandatoryTraining: TrainingRecordCategory[] = []; public qualificationsByGroup: QualificationsByGroup; public lastUpdatedDate: Date; public fragmentsObject: any = { @@ -44,6 +48,9 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe qualifications: 'qualifications', }; public pdfCount: number; + public certificateErrors: Record = {}; // {categoryName: errorMessage} + private trainingRecords: any; + constructor( private breadcrumbService: BreadcrumbService, private establishmentService: EstablishmentService, @@ -103,6 +110,7 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe this.workplace = this.route.parent.snapshot.data.establishment; this.worker = this.route.snapshot.data.worker; this.qualificationsByGroup = this.route.snapshot.data.trainingAndQualificationRecords.qualifications; + this.trainingRecords = this.route.snapshot.data.trainingAndQualificationRecords.training; this.canEditWorker = this.permissionsService.can(this.workplace.uid, 'canEditWorker'); this.canViewWorker = this.permissionsService.can(this.workplace.uid, 'canViewWorker'); this.trainingService.trainingOrQualificationPreviouslySelected = null; @@ -141,30 +149,37 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe } private setTraining(): void { - const trainingRecords = this.route.snapshot.data.trainingAndQualificationRecords.training; - this.mandatoryTraining = this.createBlankMissingMandatoryTrainings(trainingRecords.mandatory); - this.sortTrainingAlphabetically(this.mandatoryTraining); - this.nonMandatoryTraining = this.sortTrainingAlphabetically(trainingRecords.nonMandatory); - - this.mandatoryTrainingCount = this.getTrainingCount(this.mandatoryTraining); - this.nonMandatoryTrainingCount = this.getTrainingCount(this.nonMandatoryTraining); - - this.getStatus(this.mandatoryTraining); - this.getStatus(this.nonMandatoryTraining); + this.setMandatoryTraining(); + this.setMissingMandatoryTraining(this.mandatoryTraining); + this.setNonMandatoryTraining(); this.populateActionsList(this.mandatoryTraining, 'Mandatory'); + this.populateActionsList(this.missingMandatoryTraining, 'Mandatory'); this.populateActionsList(this.nonMandatoryTraining, 'Non-mandatory'); this.sortActionsList(); - this.getLastUpdatedDate([this.qualificationsByGroup?.lastUpdated, trainingRecords?.lastUpdated]); + this.getLastUpdatedDate([this.qualificationsByGroup?.lastUpdated, this.trainingRecords?.lastUpdated]); + } + + private setMandatoryTraining() { + this.mandatoryTraining = this.trainingRecords.mandatory; + this.sortTrainingAlphabetically(this.mandatoryTraining); + this.mandatoryTrainingCount = this.getTrainingCount(this.mandatoryTraining); + this.getStatus(this.mandatoryTraining); + } + + private setNonMandatoryTraining() { + this.nonMandatoryTraining = this.sortTrainingAlphabetically(this.trainingRecords.nonMandatory); + this.nonMandatoryTrainingCount = this.getTrainingCount(this.nonMandatoryTraining); + this.getStatus(this.nonMandatoryTraining); } - private createBlankMissingMandatoryTrainings(mandatoryTraining: TrainingRecordCategory[]) { + private setMissingMandatoryTraining(mandatoryTraining: TrainingRecordCategory[]): void { const trainingCategoryIds = this.getMandatoryTrainingIds(mandatoryTraining); const missingMandatoryTrainings = this.filterTrainingCategoriesWhereTrainingExists(trainingCategoryIds); - missingMandatoryTrainings.forEach((missingMandatoryTraining) => { - mandatoryTraining.push({ + this.missingMandatoryTraining = missingMandatoryTrainings.map((missingMandatoryTraining) => { + return { category: missingMandatoryTraining.category, id: missingMandatoryTraining.trainingCategoryId, trainingRecords: [ @@ -173,6 +188,7 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe id: missingMandatoryTraining.trainingCategoryId, category: missingMandatoryTraining.category, }, + trainingCertificates: [], created: null, title: null, uid: null, @@ -180,11 +196,11 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe updatedBy: null, expires: null, missing: true, + trainingStatus: 2, }, ], - }); + }; }); - return mandatoryTraining; } private filterTrainingCategoriesWhereTrainingExists(trainingCategoryIds: Array): mandatoryTraining[] { @@ -318,4 +334,64 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe public ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + public downloadTrainingCertificate(trainingRecord: TrainingRecord): void { + this.trainingService + .downloadCertificates( + this.workplace.uid, + this.worker.uid, + trainingRecord.uid, + trainingRecord.trainingCertificates, + ) + .subscribe( + () => { + this.certificateErrors = {}; + }, + (_error) => { + const categoryName = trainingRecord.trainingCategory.category; + this.certificateErrors = { + [categoryName]: "There's a problem with this download. Try again later or contact us for help.", + }; + }, + ); + } + + public uploadTrainingCertificate(event: CertificateUpload): void { + const { files, trainingRecord } = event; + + const errors = CustomValidators.validateUploadCertificates(files); + if (errors?.length > 0) { + const categoryName = trainingRecord.trainingCategory.category; + this.certificateErrors = { [categoryName]: errors[0] }; + return; + } + + this.trainingService + .addCertificateToTraining(this.workplace.uid, this.worker.uid, trainingRecord.uid, files) + .subscribe( + () => { + this.certificateErrors = {}; + this.refreshTraining().then(() => { + this.alertService.addAlert({ + type: 'success', + message: 'Certificate uploaded', + }); + }); + }, + (_error) => { + const categoryName = trainingRecord.trainingCategory.category; + this.certificateErrors = { + [categoryName]: "There's a problem with this upload. Try again later or contact us for help.", + }; + }, + ); + } + + private async refreshTraining() { + const updatedData: TrainingAndQualificationRecords = await this.workerService + .getAllTrainingAndQualificationRecords(this.workplace.uid, this.worker.uid) + .toPromise(); + this.trainingRecords = updatedData.training; + this.setTraining(); + } } diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html index 3470680d46..b6915a55ed 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html @@ -1,12 +1,18 @@
- +

{{ trainingType }}

-
+
+ + + + - + - - + + - + - + @@ -100,14 +125,26 @@

{{ trainingType }}

Mandatory training

-

No mandatory training has been added for their job role yet.

- Add and manage mandatory training categories + +

No mandatory training records have been added for this person yet.

+ Add a training record +
+ +

No mandatory training has been added for this job role yet.

+ Add and manage mandatory training categories +
diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss new file mode 100644 index 0000000000..1e78c1a751 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss @@ -0,0 +1,4 @@ +td.govuk-table__cell:has(button) { + padding-top: 6px; + padding-bottom: 6px; +} diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts index 13a340cb88..51c9af855b 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts @@ -1,14 +1,13 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { NewTrainingComponent } from './new-training.component'; describe('NewTrainingComponent', async () => { - let component: NewTrainingComponent; - let fixture: ComponentFixture; - const trainingCategories = [ { category: 'Autism', @@ -20,6 +19,7 @@ describe('NewTrainingComponent', async () => { expires: new Date('10/23/2022'), title: 'Autism training', trainingCategory: { id: 2, category: 'Autism' }, + trainingCertificates: [], uid: 'someAutismUid', trainingStatus: 1, created: new Date('10/23/2021'), @@ -32,6 +32,7 @@ describe('NewTrainingComponent', async () => { expires: new Date('10/20/2022'), title: 'Autism training 2', trainingCategory: { id: 2, category: 'Autism' }, + trainingCertificates: [], uid: 'someAutismUid2', trainingStatus: 2, created: new Date('10/20/2021'), @@ -50,6 +51,7 @@ describe('NewTrainingComponent', async () => { expires: new Date('09/20/2021'), title: 'Communication training', trainingCategory: { id: 3, category: 'Communication' }, + trainingCertificates: [], uid: 'someCommunicationUid', trainingStatus: 3, created: new Date('09/20/2020'), @@ -68,6 +70,7 @@ describe('NewTrainingComponent', async () => { expires: new Date('10/20/2022'), title: 'Health training', trainingCategory: { id: 1, category: 'Health' }, + trainingCertificates: [], uid: 'someHealthUid', trainingStatus: 0, created: new Date('10/20/2021'), @@ -80,6 +83,7 @@ describe('NewTrainingComponent', async () => { expires: new Date('10/20/2022'), title: '', trainingCategory: { id: 1, category: 'Health' }, + trainingCertificates: [], uid: 'someHealthUid2', trainingStatus: 0, created: new Date('10/20/2021'), @@ -90,29 +94,36 @@ describe('NewTrainingComponent', async () => { }, ]; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule], - declarations: [], + async function setup(canEditWorker = true, certificateErrors = null) { + const { fixture, getByTestId, getByLabelText } = await render(NewTrainingComponent, { + imports: [RouterTestingModule, HttpClientTestingModule, SharedModule], providers: [], - }).compileComponents(); - }); + componentProperties: { + canEditWorker, + trainingCategories: trainingCategories, + isMandatoryTraining: false, + certificateErrors, + }, + }); + const component = fixture.componentInstance; - beforeEach(() => { - fixture = TestBed.createComponent(NewTrainingComponent); - component = fixture.componentInstance; - component.canEditWorker = true; - component.trainingCategories = trainingCategories; - component.isMandatoryTraining = false; - fixture.detectChanges(); - }); + return { + component, + getByTestId, + getByLabelText, + fixture, + }; + } it('should create', async () => { + const { component } = await setup(); expect(component).toBeTruthy(); }); describe('training record table contents', async () => { it('should render a category heading name for each training record category', async () => { + const { fixture } = await setup(); + const autismCategory = fixture.debugElement.query(By.css('[data-testid="category-Autism"]')).nativeElement; const communicationCategory = fixture.debugElement.query( By.css('[data-testid="category-Communication"]'), @@ -125,8 +136,7 @@ describe('NewTrainingComponent', async () => { }); it('should render missing training name when there is no title for a training record', async () => { - component.canEditWorker = true; - fixture.detectChanges(); + const { fixture } = await setup(); const healthTrainingTitle = fixture.debugElement.query( By.css('[data-testid="Title-someHealthUid"]'), @@ -141,16 +151,12 @@ describe('NewTrainingComponent', async () => { }); describe('training record links', async () => { - it('training title should have link to training records if you are an edit user', () => { - component.canEditWorker = true; - fixture.detectChanges(); + it('training title should have link to training records if you are an edit user', async () => { + const { fixture } = await setup(); const autismTrainingTitleLink = fixture.debugElement.query( By.css('[data-testid="Title-someAutismUid"]'), ).nativeElement; - const autismTraining2TitleLink = fixture.debugElement.query( - By.css('[data-testid="Title-someAutismUid2"]'), - ).nativeElement; const communicationTrainingTitleLink = fixture.debugElement.query( By.css('[data-testid="Title-someCommunicationUid"]'), ).nativeElement; @@ -166,11 +172,6 @@ describe('NewTrainingComponent', async () => { .getAttribute('href') .slice(0, autismTrainingTitleLink.getAttribute('href').indexOf(';')), ).toBe('/training/someAutismUid'); - expect( - autismTraining2TitleLink - .getAttribute('href') - .slice(0, autismTraining2TitleLink.getAttribute('href').indexOf(';')), - ).toBe('/training/someAutismUid2'); expect( communicationTrainingTitleLink .getAttribute('href') @@ -188,14 +189,10 @@ describe('NewTrainingComponent', async () => { ).toBe('/training/someHealthUid2'); }); - it('training title should not link to training records if you are a read only user', () => { - component.canEditWorker = false; - fixture.detectChanges(); + it('training title should not link to training records if you are a read only user', async () => { + const { fixture } = await setup(false); const autismTrainingTitleLink = fixture.debugElement.query(By.css('[data-testid="Title-no-link-someAutismUid"]')); - const autismTraining2TitleLink = fixture.debugElement.query( - By.css('[data-testid="Title-no-link-someAutismUid2"]'), - ); const communicationTrainingTitleLink = fixture.debugElement.query( By.css('[data-testid="Title-no-link-someCommunicationUid"]'), ); @@ -206,7 +203,6 @@ describe('NewTrainingComponent', async () => { expect(autismTrainingTitleLink).toBeTruthy(); expect(autismTrainingTitleLink).toBeTruthy(); - expect(autismTraining2TitleLink).toBeTruthy(); expect(communicationTrainingTitleLink).toBeTruthy(); expect(healthTrainingTitleLink).toBeTruthy(); expect(healthTraining2TitleLink).toBeTruthy(); @@ -215,7 +211,10 @@ describe('NewTrainingComponent', async () => { describe('no training', async () => { it('should display a no training found link when there is no training and isMandatoryTraining is false and canEditWorker is true', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; + component.ngOnChanges(); fixture.detectChanges(); const noTrainingLink = fixture.debugElement.query(By.css('[data-testid="no-training-link"]')).nativeElement; @@ -224,6 +223,8 @@ describe('NewTrainingComponent', async () => { }); it('should not display a no training found link when there is no training and isMandatoryTraining is false and canEditWorker is false', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; component.canEditWorker = false; fixture.detectChanges(); @@ -233,9 +234,12 @@ describe('NewTrainingComponent', async () => { }); it('should display a no mandatory training found link when there is no mandatory training and isMandatoryTraining is true and canEditWorker is true', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; component.isMandatoryTraining = true; component.workplaceUid = '123'; + component.ngOnChanges(); fixture.detectChanges(); const noMandatoryTrainingLink = fixture.debugElement.query( By.css('[data-testid="no-mandatory-training-link"]'), @@ -245,14 +249,260 @@ describe('NewTrainingComponent', async () => { }); it('should not display a no mandatory training found link when there is no mandatory training and isMandatoryTraining is true and canEditWorker is false', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; component.isMandatoryTraining = true; component.workplaceUid = '123'; component.canEditWorker = false; + component.ngOnChanges(); fixture.detectChanges(); const noMandatoryTrainingLink = fixture.debugElement.query(By.css('[data-testid="no-mandatory-training-link"]')); expect(noMandatoryTrainingLink).toBeFalsy(); }); + + it('should display a no mandatory training for job role message when mandatory training is not required for the job role', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; + component.isMandatoryTraining = true; + component.workplaceUid = '123'; + component.missingMandatoryTraining = false; + component.ngOnChanges(); + fixture.detectChanges(); + const mandatoryTrainingMissingLink = fixture.debugElement.query(By.css('[data-testid="no-mandatory-training-link"]')); + const messageText = 'No mandatory training has been added for this job role yet.'; + const mandatoryTrainingMessage = fixture.debugElement.query(debugElement => debugElement.nativeElement.textContent === messageText); + + expect(mandatoryTrainingMessage).toBeTruthy(); + expect(mandatoryTrainingMissingLink).toBeTruthy(); + }); + + it('should display a no mandatory training for job role message when mandatory training is missing', async () => { + const { component, fixture } = await setup(); + component.trainingCategories = []; + component.isMandatoryTraining = true; + component.workplaceUid = '123'; + component.missingMandatoryTraining = true; + component.ngOnChanges(); + fixture.detectChanges(); + const mandatoryTrainingMissingLink = fixture.debugElement.query(By.css('[data-testid="mandatory-training-missing-link"]')); + const messageText = 'No mandatory training records have been added for this person yet.'; + const mandatoryTrainingMessage = fixture.debugElement.query(debugElement => debugElement.nativeElement.textContent === messageText); + + expect(mandatoryTrainingMessage).toBeTruthy(); + expect(mandatoryTrainingMissingLink).toBeTruthy(); + }); + }); + + describe('expired flag', () => { + it('should not show if there is no expiry date', async () => { + const { component, fixture } = await setup(); + + component.trainingCategories[0].trainingRecords[0].expires = null; + fixture.detectChanges(); + + const expiredAutismTrainingExpired = fixture.debugElement.query( + By.css('[data-testid="status-expired-someAutismUid"]'), + ); + const expiredAutismTrainingExpiring = fixture.debugElement.query( + By.css('[data-testid="status-expiring-someAutismUid"]'), + ); + + expect(expiredAutismTrainingExpired).toBeFalsy(); + expect(expiredAutismTrainingExpiring).toBeFalsy(); + }); + + it('should not show if expiry date has not passed', async () => { + const { component, fixture } = await setup(); + + const today = new Date(); + const yearFromNow = today.setFullYear(today.getFullYear() + 1); + + component.trainingCategories[0].trainingRecords[0].expires = new Date(yearFromNow); + component.trainingCategories[0].trainingRecords[0].trainingStatus = 0; + fixture.detectChanges(); + + const expiredAutismTrainingExpired = fixture.debugElement.query( + By.css('[data-testid="status-expired-someAutismUid"]'), + ); + const expiredAutismTrainingExpiring = fixture.debugElement.query( + By.css('[data-testid="status-expiring-someAutismUid"]'), + ); + + expect(expiredAutismTrainingExpired).toBeFalsy(); + expect(expiredAutismTrainingExpiring).toBeFalsy(); + }); + + it('should show expired if expiry date has passed', async () => { + const { component, fixture } = await setup(); + + const today = new Date(); + const yearBeforeNow = today.setFullYear(today.getFullYear() - 1); + + component.trainingCategories[0].trainingRecords[0].expires = new Date(yearBeforeNow); + component.trainingCategories[0].trainingRecords[0].trainingStatus = 3; + fixture.detectChanges(); + + const expiredAutismTrainingExpired = fixture.debugElement.query( + By.css('[data-testid="status-expired-someAutismUid"]'), + ); + const expiredAutismTrainingExpiring = fixture.debugElement.query( + By.css('[data-testid="status-expiring-someAutismUid"]'), + ); + + expect(expiredAutismTrainingExpired).toBeTruthy(); + expect(expiredAutismTrainingExpiring).toBeFalsy(); + }); + + it('should show expires soon', async () => { + const { component, fixture } = await setup(); + + const today = new Date(); + const monthFromNow = today.setMonth(today.getMonth() + 1); + + component.trainingCategories[0].trainingRecords[0].expires = new Date(monthFromNow); + component.trainingCategories[0].trainingRecords[0].trainingStatus = 1; + fixture.detectChanges(); + + const expiredAutismTrainingExpired = fixture.debugElement.query( + By.css('[data-testid="status-expired-someAutismUid"]'), + ); + const expiredAutismTrainingExpiring = fixture.debugElement.query( + By.css('[data-testid="status-expiring-someAutismUid"]'), + ); + + expect(expiredAutismTrainingExpired).toBeFalsy(); + expect(expiredAutismTrainingExpiring).toBeTruthy(); + }); + }); + + describe('Training certificates', () => { + it('should display Download link when training record has one certificate associated with it', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = [ + { + filename: 'test.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + ]; + fixture.detectChanges(); + + const trainingRecordWithCertificateRow = getByTestId('someAutismUid'); + expect(within(trainingRecordWithCertificateRow).getByText('Download')).toBeTruthy(); + }); + + it('should trigger download file emitter when Download link is clicked', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = [ + { + filename: 'test.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + ]; + fixture.detectChanges(); + + const downloadFileSpy = spyOn(component.downloadFile, 'emit'); + + const downloadLink = within(getByTestId('someAutismUid')).getByText('Download'); + + userEvent.click(downloadLink); + const expectedTrainingRecord = component.trainingCategories[0].trainingRecords[0]; + + expect(downloadFileSpy).toHaveBeenCalledOnceWith(expectedTrainingRecord); + }); + + it('should display Select a download link when training record has more than one certificate associated with it', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = [ + { + filename: 'test.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + { + filename: 'test2.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888b', + uploadDate: '2024-09-19T08:57:45.000Z', + }, + ]; + fixture.detectChanges(); + + const trainingRecordWithCertificateRow = getByTestId('someAutismUid'); + expect(within(trainingRecordWithCertificateRow).getByText('Select a download')).toBeTruthy(); + }); + + it('should have href of training record on Select a download link', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = [ + { + filename: 'test.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888a', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + { + filename: 'test2.pdf', + uid: '1872ec19-510d-41de-995d-6abfd3ae888b', + uploadDate: '2024-09-19T08:57:45.000Z', + }, + ]; + fixture.detectChanges(); + + const trainingRecordUid = component.trainingCategories[0].trainingRecords[0].uid; + + const trainingRecordWithCertificateRow = getByTestId(trainingRecordUid); + const selectADownloadLink = within(trainingRecordWithCertificateRow).getByText('Select a download'); + + expect(selectADownloadLink.getAttribute('href')).toEqual(`/training/${trainingRecordUid}`); + }); + + it('should display Upload file button when training record has no certificates associated with it', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = []; + fixture.detectChanges(); + + const trainingRecordWithCertificateRow = getByTestId('someAutismUid'); + expect(within(trainingRecordWithCertificateRow).getByText('Upload file')).toBeTruthy(); + }); + + it('should trigger the upload file emitter when a file is selected by the Upload file button', async () => { + const { component, fixture, getByTestId } = await setup(); + const mockUploadFile = new File(['some file content'], 'certificate.pdf'); + const uploadFileSpy = spyOn(component.uploadFile, 'emit'); + + component.trainingCategories[0].trainingRecords[0].trainingCertificates = []; + fixture.detectChanges(); + + const trainingRecordWithCertificateRow = getByTestId('someAutismUid'); + const fileInput = within(trainingRecordWithCertificateRow).getByTestId('fileInput'); + + userEvent.upload(fileInput, [mockUploadFile]); + + expect(uploadFileSpy).toHaveBeenCalledWith({ + files: [mockUploadFile], + trainingRecord: component.trainingCategories[0].trainingRecords[0], + }); + }); + + it('should display an error message above the category when download certificate fails', async () => { + const certificateErrors = { + Autism: "There's a problem with this download. Try again later or contact us for help.", + }; + const { getByTestId } = await setup(true, certificateErrors); + + const categorySection = getByTestId('Autism-section'); + expect( + within(categorySection).getByText( + "There's a problem with this download. Try again later or contact us for help.", + ), + ).toBeTruthy(); + }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts index 14a3588902..4ef2ba67e3 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts @@ -1,24 +1,57 @@ -import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { TrainingRecordCategory } from '@core/model/training.model'; +import { CertificateUpload, TrainingRecord, TrainingRecordCategory } from '@core/model/training.model'; import { TrainingStatusService } from '@core/services/trainingStatus.service'; @Component({ selector: 'app-new-training', templateUrl: './new-training.component.html', + styleUrls: ['./new-training.component.scss'], }) -export class NewTrainingComponent { +export class NewTrainingComponent implements OnChanges { @Input() public trainingCategories: TrainingRecordCategory[]; @Input() public isMandatoryTraining = false; @Input() public trainingType: string; @Input() public setReturnRoute: () => void; @Input() public canEditWorker: boolean; + @Input() public missingMandatoryTraining = false; + @Input() public certificateErrors: Record = {}; + @Output() public downloadFile = new EventEmitter(); + @Output() public uploadFile = new EventEmitter(); + + public trainingCategoryToDisplay: (TrainingRecordCategory & { error?: string })[]; + @ViewChild('content') public content: ElementRef; public workplaceUid: string; constructor(protected trainingStatusService: TrainingStatusService, private route: ActivatedRoute) {} - ngOnInit() { + ngOnInit(): void { this.workplaceUid = this.route.snapshot.params.establishmentuid; + this.addErrorsToTrainingCategories(); + } + + ngOnChanges(): void { + this.addErrorsToTrainingCategories(); + } + + handleDownloadCertificate(event: Event, trainingRecord: TrainingRecord) { + event.preventDefault(); + this.downloadFile.emit(trainingRecord); + } + + handleUploadCertificate(files: File[], trainingRecord: TrainingRecord) { + this.uploadFile.emit({ files, trainingRecord }); + } + + addErrorsToTrainingCategories() { + this.trainingCategoryToDisplay = this.trainingCategories.map((trainingCategory) => { + if (this.certificateErrors && trainingCategory.category in this.certificateErrors) { + const errorMessage = this.certificateErrors[trainingCategory.category]; + return { ...trainingCategory, error: errorMessage }; + } else { + return trainingCategory; + } + }); } } diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.html b/frontend/src/app/shared/components/certifications-table/certifications-table.component.html new file mode 100644 index 0000000000..7b97f31bab --- /dev/null +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.html @@ -0,0 +1,72 @@ + +
{{ trainingCategory.category @@ -15,14 +21,18 @@

{{ trainingType }}

Training nameAccreditedAccredited Completion dateExpiry dateStatusExpiry dateCertificate
- @@ -55,17 +65,11 @@

{{ trainingType }}

{{ trainingRecord?.expires ? (trainingRecord.expires | date: 'dd MMM y') : '-' }} - -
- red warning flag - Missing -
-
+
red expired flag{{ trainingType }}
{{ trainingType }} /> Expires soon
-
- OK -
-
- - -
+
+ + + Download + the certificate {{ trainingRecord.trainingCertificates[0].filename }} + + + + + Select a download + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
File nameUpload date + + + Download all + +
{{ file.name }}- + + Remove {{ file.name + ' from the files to be uploaded' }} + +
+ {{ certificate.filename }} + + {{ certificate.uploadDate | date: "d MMM y, h:mmaaaaa'm'" }} + + + Download + the certificate {{ certificate.filename }} + + + Remove the uploaded certificate {{ certificate.filename }} +
+ diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.scss b/frontend/src/app/shared/components/certifications-table/certifications-table.component.scss new file mode 100644 index 0000000000..83d7471f03 --- /dev/null +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.scss @@ -0,0 +1,20 @@ +.filename-table-header { + width: 46%; +} + +td.table__cell-file_name { + word-break: break-all; +} + +td.table__cell-date { + min-width: 220px; +} + +td.table__cell-download_button { + min-width: 155px; + width: fit-content; +} + +td.table__cell-cancel_button { + min-width: 80px; +} diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts b/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts new file mode 100644 index 0000000000..137148368f --- /dev/null +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts @@ -0,0 +1,199 @@ +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { CertificationsTableComponent } from './certifications-table.component'; + +describe('CertificationsTableComponent', () => { + let singleFile = [ + { + uid: 'uid-1', + filename: 'first_aid.pdf', + uploadDate: '2024-04-12T14:44:29.151', + }, + ]; + + let multipleFiles = [ + { + uid: 'uid-1', + filename: 'first_aid.pdf', + uploadDate: '2024-04-12T14:44:29.151', + }, + { + uid: 'uid-2', + filename: 'first_aid.pdf_v2', + uploadDate: '2024-05-12T14:44:29.151', + }, + { + uid: 'uid-3', + filename: 'first_aid.pdf_v3', + uploadDate: '2024-06-12T14:44:29.151', + }, + ]; + + const setup = async (files = [], filesToUpload = []) => { + const { fixture, getByText, getByTestId, queryByText, queryByTestId } = await render(CertificationsTableComponent, { + imports: [SharedModule], + componentProperties: { + certificates: files, + filesToUpload, + }, + }); + + const component = fixture.componentInstance; + + return { + component, + fixture, + getByText, + getByTestId, + queryByText, + queryByTestId, + }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + describe('table header', () => { + it('should show the file name and upload date', async () => { + const { getByTestId } = await setup(multipleFiles); + + const certificationsTableHeader = getByTestId('certificationsTableHeader'); + + expect(certificationsTableHeader).toBeTruthy(); + expect(within(certificationsTableHeader).getByText('File name')).toBeTruthy(); + expect(within(certificationsTableHeader).getByText('Upload date')).toBeTruthy(); + expect(within(certificationsTableHeader).getByText('Download all')).toBeTruthy(); + }); + + it('should not show the file name and upload date if there is no certificates', async () => { + const { queryByTestId } = await setup(); + + const certificationsTableHeader = queryByTestId('certificationsTableHeader'); + + expect(certificationsTableHeader).toBeFalsy(); + }); + + describe('download all', () => { + it('should show if there is more than one file', async () => { + const { getByTestId } = await setup(multipleFiles); + + const certificationsTableHeader = getByTestId('certificationsTableHeader'); + + expect(within(certificationsTableHeader).getByText('Download all')).toBeTruthy(); + }); + + it('should not show if there is only one file', async () => { + const { getByTestId } = await setup(singleFile); + + const certificationsTableHeader = getByTestId('certificationsTableHeader'); + + expect(within(certificationsTableHeader).queryByText('Download all')).toBeFalsy(); + }); + + it('should emit download event with no index when download all button clicked', async () => { + const { component, getByTestId } = await setup(multipleFiles); + + const downloadCertificateEmitSpy = spyOn(component.downloadCertificate, 'emit'); + const certificationsTableHeader = getByTestId('certificationsTableHeader'); + const downloadAllButton = within(certificationsTableHeader).getByText('Download all'); + + downloadAllButton.click(); + expect(downloadCertificateEmitSpy).toHaveBeenCalledWith(null); + }); + }); + }); + + it('should show a row of for a single file', async () => { + const { getByTestId } = await setup(singleFile); + + const certificateRow = getByTestId('certificate-row-0'); + + expect(certificateRow).toBeTruthy(); + expect(within(certificateRow).getByText('first_aid.pdf')).toBeTruthy(); + expect(within(certificateRow).getByText('12 Apr 2024, 2:44pm')).toBeTruthy(); + expect(within(certificateRow).getByText('Download')).toBeTruthy(); + expect(within(certificateRow).getByText('Remove')).toBeTruthy(); + }); + + it('should show multiple rows for multiple files', async () => { + const { getByTestId } = await setup(multipleFiles); + + expect(getByTestId('certificate-row-0')).toBeTruthy(); + expect(getByTestId('certificate-row-1')).toBeTruthy(); + expect(getByTestId('certificate-row-2')).toBeTruthy(); + }); + + it('should emit event when download button clicked with index of table row', async () => { + const { component, getByTestId } = await setup(multipleFiles); + + const certificateDownloadEmitSpy = spyOn(component.downloadCertificate, 'emit'); + const certificateRow = getByTestId('certificate-row-0'); + const downloadButton = within(certificateRow).getByText('Download'); + downloadButton.click(); + + expect(certificateDownloadEmitSpy).toHaveBeenCalledWith(0); + }); + + describe('Files to upload', () => { + const mockUploadFiles = ['new file1.pdf', 'new file2.pdf'].map( + (filename) => new File(['some file content'], filename, { type: 'application/pdf' }), + ); + + it('should show the file names for the new files to be uploaded', async () => { + const { getByTestId } = await setup([], mockUploadFiles); + + mockUploadFiles.forEach((file, index) => { + const uploadFileRow = getByTestId(`upload-file-row-${index}`); + expect(uploadFileRow).toBeTruthy(); + expect(within(uploadFileRow).getByText(file.name)).toBeTruthy(); + expect(within(uploadFileRow).getByText('Remove')).toBeTruthy(); + }); + }); + + it('should not show download buttons or download all button', async () => { + const { queryByText } = await setup([], mockUploadFiles); + + expect(queryByText('Download all')).toBeFalsy(); + expect(queryByText('Download')).toBeFalsy(); + }); + + it('should co-exist with the already uploaded files', async () => { + const { getByTestId } = await setup(multipleFiles, mockUploadFiles); + + expect(getByTestId('upload-file-row-0')).toBeTruthy(); + expect(getByTestId('upload-file-row-1')).toBeTruthy(); + expect(getByTestId('certificate-row-0')).toBeTruthy(); + expect(getByTestId('certificate-row-1')).toBeTruthy(); + expect(getByTestId('certificate-row-2')).toBeTruthy(); + }); + + it('should call removeFileToUpload with file index when the remove button for upload file is clicked', async () => { + const { getByTestId, component } = await setup([], mockUploadFiles); + const uploadFileRow = getByTestId('upload-file-row-1'); + + const removeFileToUploadSpy = spyOn(component.removeFileToUpload, 'emit'); + + const removeButton = within(uploadFileRow).getByText('Remove'); + userEvent.click(removeButton); + + expect(removeFileToUploadSpy).toHaveBeenCalledWith(1); + }); + }); + + it('should call removeSavedFile with file index when the remove button for upload file is clicked', async () => { + const { getByTestId, component } = await setup(multipleFiles); + + const removeFileRow = getByTestId('certificate-row-0'); + + const removeSavedFileSpy = spyOn(component.removeSavedFile, 'emit'); + + const removeButton = within(removeFileRow).getByText('Remove'); + userEvent.click(removeButton); + + expect(removeSavedFileSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts new file mode 100644 index 0000000000..9d75ee0326 --- /dev/null +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { TrainingCertificate } from '@core/model/training.model'; + +@Component({ + selector: 'app-certifications-table', + templateUrl: './certifications-table.component.html', + styleUrls: ['./certifications-table.component.scss'], +}) +export class CertificationsTableComponent implements OnInit { + @Input() certificates: TrainingCertificate[] = []; + @Input() filesToUpload: File[] = []; + @Output() removeFileToUpload = new EventEmitter(); + @Output() removeSavedFile = new EventEmitter(); + @Output() downloadCertificate = new EventEmitter(); + + ngOnInit() {} + + public handleRemoveUploadFile(event: Event, index: number): void { + event.preventDefault(); + this.removeFileToUpload.emit(index); + } + + public handleRemoveSavedFile(event: Event, index: number): void { + event.preventDefault(); + this.removeSavedFile.emit(index); + } + + public handleDownloadCertificate(event: Event, index: number): void { + event.preventDefault(); + this.downloadCertificate.emit(index); + } +} diff --git a/frontend/src/app/shared/components/drag-and-drop/validation-error-message/validation-error-message.component.html b/frontend/src/app/shared/components/drag-and-drop/validation-error-message/validation-error-message.component.html index 99df6550b8..c93b95e35b 100644 --- a/frontend/src/app/shared/components/drag-and-drop/validation-error-message/validation-error-message.component.html +++ b/frontend/src/app/shared/components/drag-and-drop/validation-error-message/validation-error-message.component.html @@ -1,11 +1,11 @@ -
+
-

+

{{ errorMessage }}

diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.html b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.html new file mode 100644 index 0000000000..cfb66b85d6 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.html @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.scss b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.scss new file mode 100644 index 0000000000..b7a7ac3ffe --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.scss @@ -0,0 +1,3 @@ +input.select-upload-file-hidden { + display: none; +} diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.spec.ts b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.spec.ts new file mode 100644 index 0000000000..225fd6a4d5 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.spec.ts @@ -0,0 +1,89 @@ +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; + +import { SelectUploadFileComponent } from './select-upload-file.component'; +import userEvent from '@testing-library/user-event'; + +describe('SelectUploadFileComponent', () => { + const setup = async (inputOverride = {}) => { + const { fixture, getByTestId, getByRole } = await render(SelectUploadFileComponent, { + imports: [SharedModule], + componentProperties: { + ...inputOverride, + }, + }); + + const component = fixture.componentInstance; + + return { + component, + fixture, + getByRole, + getByTestId, + }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + describe('rendering', () => { + it('should display a button for file selection', async () => { + const { getByRole } = await setup(); + const fileSelectButton = getByRole('button', { name: 'Choose file' }); + + expect(fileSelectButton).toBeTruthy(); + }); + + it('should create an invisible file input element for file selection', async () => { + const { getByTestId } = await setup(); + const fileInput = getByTestId('fileInput'); + + expect(fileInput).toBeTruthy(); + expect(fileInput.style.display).toBeFalsy(); + }); + + it('should display the button with the provided button text', async () => { + const { getByRole } = await setup({ buttonText: 'Upload file' }); + const fileSelectButton = getByRole('button', { name: 'Upload file' }); + + expect(fileSelectButton).toBeTruthy(); + }); + + it('should pass the `accept` and `multiple` attributes to the file input element underneath', async () => { + const { getByTestId } = await setup({ accept: '.csv', multiple: 'true' }); + const fileInput = getByTestId('fileInput') as HTMLInputElement; + + expect(fileInput.accept).toEqual('.csv'); + expect(fileInput.multiple).toEqual(true); + }); + }); + + describe('File selection', () => { + it('should trigger the fileInput by clicking the button', async () => { + const { getByRole, getByTestId } = await setup(); + const fileSelectButton = getByRole('button', { name: 'Choose file' }); + const fileInput = getByTestId('fileInput'); + + const spyFileInputClick = spyOn(fileInput, 'click').and.callThrough(); + + userEvent.click(fileSelectButton); + + expect(spyFileInputClick).toHaveBeenCalledTimes(1); + }); + + it('should output the files that user has selected', async () => { + const { getByTestId, component } = await setup({ multiple: true }); + const selectFilesSpy = spyOn(component.selectFiles, 'emit').and.callThrough(); + const fileInput = getByTestId('fileInput'); + + const mockFile1 = new File(['some file content'], 'cert1.pdf', { type: 'application/pdf' }); + const mockFile2 = new File(['some file content'], 'cert2.pdf', { type: 'application/pdf' }); + + userEvent.upload(fileInput, [mockFile1, mockFile2]); + + expect(selectFilesSpy).toHaveBeenCalledWith([mockFile1, mockFile2]); + }); + }); +}); diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts new file mode 100644 index 0000000000..0f347b7489 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts @@ -0,0 +1,30 @@ +import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-select-upload-file', + templateUrl: './select-upload-file.component.html', + styleUrls: ['./select-upload-file.component.scss'], +}) +export class SelectUploadFileComponent implements OnInit { + @Input() accept: string; + @Input() multiple: boolean = false; + @Input() buttonId: string | null = 'select-upload-file'; + @Input('aria-describedby') ariaDescribedBy: string | null; + @Input() buttonText: string = 'Choose file'; + @Input() buttonClasses: string = ''; + + @Output() selectFiles = new EventEmitter(); + + ngOnInit() {} + + handleChange(event: Event) { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + const selectedFiles = Array.from(event.target.files); + if (selectedFiles?.length) { + this.selectFiles.emit(selectedFiles); + } + } +} diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html index c92a4cc002..868bac628d 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html @@ -2,227 +2,311 @@
-
+
- - {{ section }} -

- {{ title }} -

-
-
- Number of staff selected -
-
+
+
+ + {{ section }} +

+ {{ title }} +

+
+
+ Number of staff selected
-
-

{{ workerCount }}

-
-
-

- Change -

+
+
+
+

{{ workerCount }}

+
+
+

+ Change +

+
+
-
-
- -
- Training category -
-
+ +
+ Training category
-
-

{{ category }}

-
-
-

- Change -

+
+
+
+

{{ category }}

+
+
+

+ Change +

+
+
+ + +

+ Training category + {{ category }} + Change + +

+
+ +
+ + + Error: {{ getFirstErrorMessage('title') }} + +
-
-
- -

- Training category - {{ category }} - Change - -

-
-
- - - Error: {{ getFirstErrorMessage('title') }} - - -
+
+
+ Is the training accredited? +
+
+ + +
+
+ + +
+
+ + +
+
+
+
-
-
- Is the training accredited? -
-
- - -
-
- - -
-
- - -
+
+
+ Date completed + For example, 31 3 1980 + + Error: {{ getFirstErrorMessage('completed') }} + + +
-
-
-
-
- Date completed - For example, 31 3 1980 - - Error: {{ getFirstErrorMessage('completed') }} - - -
+
+
+ Expiry date + For example, 31 3 1980 + + Error: {{ getFirstErrorMessage('expires') }} + + +
+
+
+
- -
-
- Expiry date - For example, 31 3 1980 - - Error: {{ getFirstErrorMessage('expires') }} - - -
+
+
+
+

Certificates

+
+ +
+ The certificate must be a PDF file that's no larger than 500KB +
+
+ + + {{ filesToUpload?.length > 0 ? filesToUpload.length + ' file chosen' : 'No file chosen' }} + +
+
+ +
+
+
+ +
+
+
+
+
+ + + Error: {{ getFirstErrorMessage('notes') }} + + +
+ + + + + You have {{ remainingCharacterCount | absoluteNumber | number }} + {{ + remainingCharacterCount + | absoluteNumber + | i18nPlural + : { + '=1': 'character', + other: 'characters' + } + }} -
-
- - - Error: {{ getFirstErrorMessage('notes') }} - - - + {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} + + +
+
+
- - - - You have {{ remainingCharacterCount | absoluteNumber | number }} - {{ - remainingCharacterCount - | absoluteNumber - | i18nPlural - : { - '=1': 'character', - other: 'characters' - } - }} - - {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} - -
-
- -
-
- - - Cancel - +
+ + + + Cancel + +
+
diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.directive.ts b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.directive.ts index b4893276d3..32a9b0dcfe 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.directive.ts +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.directive.ts @@ -5,7 +5,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { DATE_PARSE_FORMAT } from '@core/constants/constants'; import { ErrorDetails } from '@core/model/errorSummary.model'; import { Establishment } from '@core/model/establishment.model'; -import { TrainingCategory, TrainingRecord, TrainingRecordRequest } from '@core/model/training.model'; +import { + TrainingCategory, + TrainingCertificate, + TrainingRecord, + TrainingRecordRequest, +} from '@core/model/training.model'; import { Worker } from '@core/model/worker.model'; import { AlertService } from '@core/services/alert.service'; import { BackLinkService } from '@core/services/backLink.service'; @@ -30,6 +35,7 @@ export class AddEditTrainingDirective implements OnInit, AfterViewInit { public workplace: Establishment; public formErrorsMap: Array; public notesMaxLength = 1000; + public notesOpen = false; private titleMaxLength = 120; private titleMinLength = 3; public subscriptions: Subscription = new Subscription(); @@ -43,6 +49,8 @@ export class AddEditTrainingDirective implements OnInit, AfterViewInit { public notesValue = ''; public showChangeLink: boolean = false; public multipleTrainingDetails: boolean; + public trainingCertificates: TrainingCertificate[] = []; + public submitButtonDisabled: boolean = false; constructor( protected formBuilder: UntypedFormBuilder, @@ -223,6 +231,9 @@ export class AddEditTrainingDirective implements OnInit, AfterViewInit { this.errorSummaryService.syncFormErrorsEvent.next(true); if (!this.form.valid) { + if (this.form.controls.notes?.errors?.maxlength) { + this.notesOpen = true; + } this.errorSummaryService.scrollToErrorSummary(); return; } @@ -316,4 +327,8 @@ export class AddEditTrainingDirective implements OnInit, AfterViewInit { ]); } } + + public toggleNotesOpen(): void { + this.notesOpen = !this.notesOpen; + } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 524ed0a903..86d63d2e68 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -121,6 +121,8 @@ import { WorkerPayPipe } from './pipes/worker-pay.pipe'; import { WorkplacePermissionsBearerPipe } from './pipes/workplace-permissions-bearer.pipe'; import { RadioButtonAccordionComponent } from './components/accordions/radio-button-accordion/radio-button-accordion.component'; import { GroupedRadioButtonAccordionComponent } from './components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component'; +import { CertificationsTableComponent } from './components/certifications-table/certifications-table.component'; +import { SelectUploadFileComponent } from './components/select-upload-file/select-upload-file.component'; @NgModule({ imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, OverlayModule], @@ -243,6 +245,8 @@ import { GroupedRadioButtonAccordionComponent } from './components/accordions/ra NavigateToWorkplaceDropdownComponent, OtherLinksComponent, NewTrainingLinkPanelComponent, + CertificationsTableComponent, + SelectUploadFileComponent, ], exports: [ AbsoluteNumberPipe, @@ -361,6 +365,8 @@ import { GroupedRadioButtonAccordionComponent } from './components/accordions/ra NavigateToWorkplaceDropdownComponent, OtherLinksComponent, NewTrainingLinkPanelComponent, + CertificationsTableComponent, + SelectUploadFileComponent, ], providers: [DialogService, TotalStaffComponent, ArticleListResolver, PageResolver], }) diff --git a/frontend/src/app/shared/validators/custom-form-validators.ts b/frontend/src/app/shared/validators/custom-form-validators.ts index 8357d8598f..caaa5a2402 100644 --- a/frontend/src/app/shared/validators/custom-form-validators.ts +++ b/frontend/src/app/shared/validators/custom-form-validators.ts @@ -129,4 +129,19 @@ export class CustomValidators extends Validators { return null; } + + static validateUploadCertificates(files: File[]): string[] | null { + let errors = []; + const maxFileSize = 500 * 1024; + + if (files.some((file) => file.size > maxFileSize)) { + errors.push('The certificate must be no larger than 500KB'); + } + + if (files.some((file) => !file.name.toLowerCase().endsWith('.pdf'))) { + errors.push('The certificate must be a PDF file'); + } + + return errors.length ? errors : null; + } } diff --git a/frontend/src/assets/scss/modules/_utils.scss b/frontend/src/assets/scss/modules/_utils.scss index ac170df3ad..3de97e408c 100644 --- a/frontend/src/assets/scss/modules/_utils.scss +++ b/frontend/src/assets/scss/modules/_utils.scss @@ -112,6 +112,10 @@ vertical-align: center; } +.govuk-util__vertical-align-bottom { + vertical-align: bottom; +} + .govuk-line-height__normal { line-height: normal; } From d31e0298f5a67a7886e02b431c30120623dd657b Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Tue, 8 Oct 2024 12:32:36 +0100 Subject: [PATCH 002/100] (WIP) temp store current changes --- .../select-upload-certificate.component.html | 33 +++++++++++++++++++ .../select-upload-certificate.component.scss | 0 ...elect-upload-certificate.component.spec.ts | 23 +++++++++++++ .../select-upload-certificate.component.ts | 26 +++++++++++++++ .../add-edit-training.component.html | 5 +-- frontend/src/app/shared/shared.module.ts | 3 ++ 6 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html create mode 100644 frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.scss create mode 100644 frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts create mode 100644 frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html new file mode 100644 index 0000000000..88c2409d29 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html @@ -0,0 +1,33 @@ +
+

Certificates

+
+ +
+ The certificate must be a PDF file that's no larger than 500KB +
+
+ + + {{ filesToUpload?.length > 0 ? filesToUpload.length + ' file chosen' : 'No file chosen' }} + +
+
+ +
+
+
diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.scss b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts new file mode 100644 index 0000000000..cf47191466 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SelectUploadCertificateComponent } from './select-upload-certificate.component'; + +describe('SelectUploadCertificateComponent', () => { + let component: SelectUploadCertificateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ SelectUploadCertificateComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SelectUploadCertificateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts new file mode 100644 index 0000000000..d25480a1fd --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-select-upload-certificate', + templateUrl: './select-upload-certificate.component.html', + styleUrls: ['./select-upload-certificate.component.scss'], +}) +export class SelectUploadCertificateComponent { + @Input() filesToUpload: File[]; + @Input() certificateErrors: string[] | null; + @Output() selectFiles = new EventEmitter; + + public getUploadComponentAriaDescribedBy(): string { + if (this.certificateErrors) { + return 'uploadCertificate-errors uploadCertificate-aria-text'; + } else if (this.filesToUpload?.length > 0) { + return 'uploadCertificate-aria-text'; + } else { + return 'uploadCertificate-hint uploadCertificate-aria-text'; + } + } + + public onSelectFiles(newFiles: File[]): void { + this.selectFiles.emit(newFiles) + } +} diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html index 868bac628d..662875ce3d 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html @@ -182,7 +182,8 @@

-
+ + Date: Wed, 9 Oct 2024 15:05:53 +0100 Subject: [PATCH 003/100] add certificate related html elements to add-edit-qualification component --- .../src/app/core/model/qualification.model.ts | 4 + .../model/trainingAndQualifications.model.ts | 6 +- .../add-edit-qualification.component.html | 12 +- .../add-edit-qualification.component.spec.ts | 104 +++++++++++++++++- .../add-edit-qualification.component.ts | 43 ++++++++ .../certifications-table.component.ts | 4 +- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/core/model/qualification.model.ts b/frontend/src/app/core/model/qualification.model.ts index dafb91c48f..fc5f92d8e2 100644 --- a/frontend/src/app/core/model/qualification.model.ts +++ b/frontend/src/app/core/model/qualification.model.ts @@ -1,3 +1,5 @@ +import { TrainingCertificate } from './training.model'; + export enum QualificationType { NVQ = 'NVQ', Other = 'Other type of qualification', @@ -82,3 +84,5 @@ export interface BasicQualificationRecord { uid: string; year: number; } + +export interface QualificationCertificate extends TrainingCertificate {} diff --git a/frontend/src/app/core/model/trainingAndQualifications.model.ts b/frontend/src/app/core/model/trainingAndQualifications.model.ts index ac9d5a7b4f..2e1e4391b9 100644 --- a/frontend/src/app/core/model/trainingAndQualifications.model.ts +++ b/frontend/src/app/core/model/trainingAndQualifications.model.ts @@ -1,5 +1,5 @@ -import { QualificationsByGroup } from './qualification.model'; -import { TrainingRecords } from './training.model'; +import { QualificationsByGroup, QualificationCertificate } from './qualification.model'; +import { TrainingRecords, TrainingCertificate } from './training.model'; export interface TrainingAndQualificationRecords { qualifications: QualificationsByGroup; @@ -14,3 +14,5 @@ export interface TrainingCounts { missingMandatoryTraining?: number; staffMissingMandatoryTraining?: number; } + +export type Certificate = TrainingCertificate | QualificationCertificate; diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 7bd3cb3401..23ab2b45f5 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -72,6 +72,16 @@

Type: {{ qualificationType }}

[pattern]="intPattern" />
+ + + +
@@ -111,7 +121,7 @@

Type: {{ qualificationType }}

'govuk-error-message': remainingCharacterCount < 0 }" aria-live="polite" - > + > You have {{ remainingCharacterCount | absoluteNumber | number }} {{ diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index a0858f3be0..6778dcd96b 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -12,13 +12,13 @@ import { qualificationRecord } from '@core/test-utils/MockWorkerService'; import { MockWorkerServiceWithWorker } from '@core/test-utils/MockWorkerServiceWithWorker'; import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; -import { fireEvent, render } from '@testing-library/angular'; +import { fireEvent, render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; import { of } from 'rxjs'; import { AddEditQualificationComponent } from './add-edit-qualification.component'; -describe('AddEditQualificationComponent', () => { +fdescribe('AddEditQualificationComponent', () => { async function setup(qualificationId = '1', qualificationInService = null) { const { fixture, getByText, getByTestId, queryByText, getByLabelText, getAllByText } = await render( AddEditQualificationComponent, @@ -231,6 +231,106 @@ describe('AddEditQualificationComponent', () => { }); }); + fdescribe('qualification certificates', () => { + const mockQualification = { group: QualificationType.NVQ, id: 10, title: 'Worker safety qualification' }; + const mockUploadFile1 = new File(['file content'], 'worker safety 2023.pdf', { type: 'application/pdf' }); + const mockUploadFile2 = new File(['file content'], 'worker safety 2024.pdf', { type: 'application/pdf' }); + + describe('certificates to be uploaded', () => { + it('should add a new upload file to the certification table when a file is selected', async () => { + const { component, fixture, getByTestId } = await setup(null, mockQualification); + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = within(uploadSection).getByTestId('fileInput'); + + expect(fileInput).toBeTruthy(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + fixture.detectChanges(); + + const certificationTable = getByTestId('qualificationCertificatesTable'); + + expect(certificationTable).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(component.filesToUpload).toEqual([mockUploadFile1]); + }); + + it('should remove an upload file when its remove button is clicked', async () => { + const { component, fixture, getByTestId, getByText } = await setup(); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + const certificationTable = getByTestId('qualificationCertificatesTable'); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile2.name)).toBeTruthy(); + + const rowForFile1 = getByText(mockUploadFile1.name).parentElement; + const removeButtonForFile1 = within(rowForFile1).getByText('Remove'); + + userEvent.click(removeButtonForFile1); + + expect(within(certificationTable).queryByText(mockUploadFile1.name)).toBeFalsy(); + + expect(within(certificationTable).queryByText(mockUploadFile2.name)).toBeTruthy(); + expect(component.filesToUpload).toHaveSize(1); + expect(component.filesToUpload[0]).toEqual(mockUploadFile2); + }); + }); + + describe('saved certificates', () => { + const savedCertificates = [ + { + uid: 'uid-1', + filename: 'worker_safety_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'worker_safety_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-3', + filename: 'worker_safety_v3.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + it('should display a row for each certificate', async () => { + const { component, fixture, getByTestId } = await setup(); + component.qualificationCertificates = savedCertificates; + + fixture.detectChanges(); + + savedCertificates.forEach((certificate, index) => { + const certificateRow = getByTestId(`certificate-row-${index}`); + expect(certificateRow).toBeTruthy(); + expect(within(certificateRow).getByText(certificate.filename)).toBeTruthy(); + expect(within(certificateRow).getByText('Download')).toBeTruthy(); + expect(within(certificateRow).getByText('Remove')).toBeTruthy(); + }); + }); + + it('should remove a file from the table when the remove button is clicked', async () => { + const { component, fixture, getByTestId, queryByText } = await setup(); + + component.qualificationCertificates = savedCertificates; + + fixture.detectChanges(); + + const certificateRow2 = getByTestId('certificate-row-2'); + const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + + fireEvent.click(removeButtonForRow2); + fixture.detectChanges(); + + expect(component.qualificationCertificates.length).toBe(2); + expect(queryByText(savedCertificates[2].filename)).toBeFalsy(); + }); + }); + }); + describe('setting data from qualification service', () => { const mockQualification = { group: QualificationType.NVQ, id: 10, title: 'Worker safety qualification' }; diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts index da7d940951..0e56a4ac59 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts @@ -6,6 +6,7 @@ import { ErrorDetails } from '@core/model/errorSummary.model'; import { Establishment } from '@core/model/establishment.model'; import { Qualification, + QualificationCertificate, QualificationRequest, QualificationResponse, QualificationType, @@ -16,6 +17,7 @@ import { ErrorSummaryService } from '@core/services/error-summary.service'; import { QualificationService } from '@core/services/qualification.service'; import { TrainingService } from '@core/services/training.service'; import { WorkerService } from '@core/services/worker.service'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; import dayjs from 'dayjs'; import { Subscription } from 'rxjs'; @@ -46,6 +48,10 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { public selectedQualification: Qualification; public qualificationType: string; public qualificationTitle: string; + public qualificationCertificates: QualificationCertificate[] = []; + private _filesToUpload: File[]; + public filesToRemove: QualificationCertificate[] = []; + public certificateErrors: string[] | null; constructor( private trainingService: TrainingService, @@ -231,6 +237,43 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { this.notesOpen = !this.notesOpen; } + get filesToUpload(): File[] { + return this._filesToUpload ?? []; + } + + private set filesToUpload(files: File[]) { + this._filesToUpload = files ?? []; + } + + private resetUploadFilesError(): void { + this.certificateErrors = null; + } + + public onSelectFiles(newFiles: File[]): void { + this.resetUploadFilesError(); + const errors = CustomValidators.validateUploadCertificates(newFiles); + + if (errors) { + this.certificateErrors = errors; + return; + } + + const combinedFiles = [...newFiles, ...this.filesToUpload]; + this.filesToUpload = combinedFiles; + } + + public removeFileToUpload(fileIndexToRemove: number): void { + const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); + this.filesToUpload = filesToKeep; + } + + public removeSavedFile(fileIndexToRemove: number): void { + this.filesToRemove.push(this.qualificationCertificates[fileIndexToRemove]); + this.qualificationCertificates = this.qualificationCertificates.filter( + (_certificate, index) => index !== fileIndexToRemove, + ); + } + protected navigateToDeleteQualificationRecord(): void { this.router.navigate([ '/workplace', diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts index 9d75ee0326..34d00c9a1e 100644 --- a/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { TrainingCertificate } from '@core/model/training.model'; +import { Certificate } from '@core/model/trainingAndQualifications.model'; @Component({ selector: 'app-certifications-table', @@ -7,7 +7,7 @@ import { TrainingCertificate } from '@core/model/training.model'; styleUrls: ['./certifications-table.component.scss'], }) export class CertificationsTableComponent implements OnInit { - @Input() certificates: TrainingCertificate[] = []; + @Input() certificates: Certificate[] = []; @Input() filesToUpload: File[] = []; @Output() removeFileToUpload = new EventEmitter(); @Output() removeSavedFile = new EventEmitter(); From 1dba8be4155f3e546fddfb7cc1f8ad01be011770 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Wed, 9 Oct 2024 15:22:47 +0100 Subject: [PATCH 004/100] amend css to allow certificate table display in full width --- .../add-edit-qualification.component.html | 166 +++++++++--------- 1 file changed, 85 insertions(+), 81 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 23ab2b45f5..ab43695bd1 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -51,98 +51,102 @@

Type: {{ qualificationType }}

-
-
- - - Error: {{ getFirstErrorMessage('year') }} - - +
+
+ + + Error: {{ getFirstErrorMessage('year') }} + + +
- - - -
-
- - - Error: {{ getFirstErrorMessage('notes') }} - - -
- - - - You have {{ remainingCharacterCount | absoluteNumber | number }} - {{ - remainingCharacterCount - | absoluteNumber - | i18nPlural - : { - '=1': 'character', - other: 'characters' - } - }} +
+ + +
- {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} -
+
+
+
+ + + Error: {{ getFirstErrorMessage('notes') }} + +
+ + + + You have {{ remainingCharacterCount | absoluteNumber | number }} + {{ + remainingCharacterCount + | absoluteNumber + | i18nPlural + : { + '=1': 'character', + other: 'characters' + } + }} + + {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} + + +
-
From 0a56efc7fde2b03c4c4be0331a24200ca25f314e Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Wed, 9 Oct 2024 15:44:03 +0100 Subject: [PATCH 005/100] add validation for cert upload --- .../add-edit-qualification.component.html | 2 +- .../add-edit-qualification.component.spec.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index ab43695bd1..f5c4ae3904 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -75,7 +75,7 @@

Type: {{ qualificationType }}

- + { expect(getByText('Close notes')).toBeTruthy(); expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); }); + + fdescribe('uploadCertificate errors', () => { + it('should show an error message if the selected file is over 500 KB', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'large-file.pdf', { type: 'application/pdf' }); + Object.defineProperty(mockUploadFile, 'size', { + value: 10 * 1024 * 1024, // 10MB + }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, mockUploadFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be no larger than 500KB')).toBeTruthy(); + }); + + it('should show an error message if the selected file is not a pdf file', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, [mockUploadFile]); + + fixture.detectChanges(); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + }); + + it('should clear the error message when user select a valid file instead', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const invalidFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + const validFile = new File(['some file content'], 'certificate.pdf', { type: 'application/pdf' }); + + const fileInputButton = getByTestId('fileInput'); + userEvent.upload(fileInputButton, [invalidFile]); + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + userEvent.upload(fileInputButton, [validFile]); + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); + + it('should provide aria description to screen reader users when error happen', async () => { + const { fixture, getByTestId } = await setup(null); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'non-pdf-file.csv')); + + const uploadButton = within(uploadSection).getByRole('button', { + description: /Error: The certificate must be a PDF file/, + }); + expect(uploadButton).toBeTruthy(); + }); + }); }); }); From 8cc404426d2834810f6daf221ac683a930b0a0b1 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Wed, 9 Oct 2024 16:24:42 +0100 Subject: [PATCH 006/100] add more unit tests --- .../add-edit-qualification.component.html | 1 + .../add-edit-qualification.component.spec.ts | 43 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index f5c4ae3904..71767d9814 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -77,6 +77,7 @@

Type: {{ qualificationType }}

{ async function setup(qualificationId = '1', qualificationInService = null) { - const { fixture, getByText, getByTestId, queryByText, getByLabelText, getAllByText } = await render( + const { fixture, getByText, getByTestId, queryByText, queryByTestId, getByLabelText, getAllByText } = await render( AddEditQualificationComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], @@ -68,6 +68,7 @@ fdescribe('AddEditQualificationComponent', () => { getByText, getByTestId, queryByText, + queryByTestId, getByLabelText, routerSpy, getAllByText, @@ -231,7 +232,7 @@ fdescribe('AddEditQualificationComponent', () => { }); }); - fdescribe('qualification certificates', () => { + describe('qualification certificates', () => { const mockQualification = { group: QualificationType.NVQ, id: 10, title: 'Worker safety qualification' }; const mockUploadFile1 = new File(['file content'], 'worker safety 2023.pdf', { type: 'application/pdf' }); const mockUploadFile2 = new File(['file content'], 'worker safety 2024.pdf', { type: 'application/pdf' }); @@ -297,6 +298,25 @@ fdescribe('AddEditQualificationComponent', () => { }, ]; + it('should show the table when there are certificates', async () => { + const { component, fixture, getByTestId } = await setup(); + + component.qualificationCertificates = savedCertificates; + fixture.detectChanges(); + + expect(getByTestId('qualificationCertificatesTable')).toBeTruthy(); + }); + + it('should not show the table when there are no certificates', async () => { + const { component, fixture, queryByTestId } = await setup(); + + component.qualificationCertificates = []; + + fixture.detectChanges(); + + expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + }); + it('should display a row for each certificate', async () => { const { component, fixture, getByTestId } = await setup(); component.qualificationCertificates = savedCertificates; @@ -322,12 +342,27 @@ fdescribe('AddEditQualificationComponent', () => { const certificateRow2 = getByTestId('certificate-row-2'); const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); - fireEvent.click(removeButtonForRow2); + userEvent.click(removeButtonForRow2); fixture.detectChanges(); expect(component.qualificationCertificates.length).toBe(2); expect(queryByText(savedCertificates[2].filename)).toBeFalsy(); }); + + it('should not show the table when all files are removed', async () => { + const { component, fixture, getByText, queryByTestId } = await setup(); + component.qualificationCertificates = savedCertificates; + + fixture.autoDetectChanges(); + + savedCertificates.forEach((certificate) => { + const certificateRow = getByText(certificate.filename).parentElement; + const removeButton = within(certificateRow).getByText('Remove'); + userEvent.click(removeButton); + }); + + expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + }); }); }); @@ -570,7 +605,7 @@ fdescribe('AddEditQualificationComponent', () => { expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); }); - fdescribe('uploadCertificate errors', () => { + describe('uploadCertificate errors', () => { it('should show an error message if the selected file is over 500 KB', async () => { const { fixture, getByTestId, getByText } = await setup(null); From 1aa727d80152458e9e80375f639e18276a6552f1 Mon Sep 17 00:00:00 2001 From: Jonathan Hardy Date: Wed, 9 Oct 2024 17:21:01 +0100 Subject: [PATCH 007/100] added new qualificationCertificates table migration, moved training certificate upload functionality into new generic worker certificate service --- ...reateTableForQualificationsCertificates.js | 71 +++++++ .../workerCertificate/trainingCertificate.js | 188 ++++------------- .../workerCertificateService.js | 191 ++++++++++++++++++ 3 files changed, 300 insertions(+), 150 deletions(-) create mode 100644 backend/migrations/20241008122413-createTableForQualificationsCertificates.js create mode 100644 backend/server/routes/establishments/workerCertificate/workerCertificateService.js diff --git a/backend/migrations/20241008122413-createTableForQualificationsCertificates.js b/backend/migrations/20241008122413-createTableForQualificationsCertificates.js new file mode 100644 index 0000000000..30f1438e81 --- /dev/null +++ b/backend/migrations/20241008122413-createTableForQualificationsCertificates.js @@ -0,0 +1,71 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.createTable( + 'QualificationCertificates', + { + ID: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + UID: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()'), + allowNull: false, + unique: true, + }, + WorkerQualificationsFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'WorkerQualifications', + schema: 'cqc', + }, + key: 'ID', + }, + }, + WorkerFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'Worker', + schema: 'cqc', + }, + key: 'ID', + }, + }, + FileName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + UploadDate: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + Key: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + }, + { schema: 'cqc' }, + ); + }, + + async down(queryInterface) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + return queryInterface.dropTable({ + tableName: 'QualificationCertificates', + schema: 'cqc', + }); + }, +}; diff --git a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js index d6122995e6..951a08cc70 100644 --- a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js +++ b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js @@ -13,177 +13,68 @@ const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlE const router = express.Router({ mergeParams: true }); -const makeFileKey = (establishmentUid, workerId, trainingUid, fileId) => { - return `${establishmentUid}/${workerId}/trainingCertificate/${trainingUid}/${fileId}`; -}; +const initialiseCertificateService = () => { + return new WorkerCertificateService(models.trainingCertificates, models.workerTraining, 'training'); +} + +const formatRequest = (req) => { + return { + files: req.body, + params: { + id: req.params.id, + workerId: req.params.workerId, + recordUid: req.params.trainingUid + } + }; +} const requestUploadUrl = async (req, res) => { - const { files } = req.body; - const { id, workerId, trainingUid } = req.params; - if (!files || !files.length) { - return res.status(400).send('Missing `files` param in request body'); - } + const certificateService = initialiseCertificateService(); - if (!files.every((file) => file?.filename)) { - return res.status(400).send('Missing file name in request body'); - } + const request = formatRequest(req); - const responsePayload = []; - - for (const file of files) { - const filename = file.filename; - const fileId = uuidv4(); - const key = makeFileKey(id, workerId, trainingUid, fileId); - const signedUrl = await s3.getSignedUrlForUpload({ - bucket: certificateBucket, - key, - options: { expiresIn: uploadSignedUrlExpire }, - }); - responsePayload.push({ filename, signedUrl, fileId, key }); + try { + const responsePayload = await certificateService.requestUploadUrl(request); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return res.status(err.statusCode).send(err.message); } - - return res.status(200).json({ files: responsePayload }); }; const confirmUpload = async (req, res) => { - const { establishmentId } = req; - const { trainingUid } = req.params; - const { files } = req.body; - - if (!files || !files.length) { - return res.status(400).send('Missing `files` param in request body'); - } - - const trainingRecord = await models.workerTraining.findOne({ - where: { - uid: trainingUid, - }, - attributes: ['id', 'workerFk'], - }); - - if (!trainingRecord) { - return res.status(400).send('Failed to find related training record'); - } + const certificateService = initialiseCertificateService(); - const { workerFk, id: trainingRecordId } = trainingRecord.dataValues; - - const etagsMatchRecord = await verifyEtagsForAllFiles(establishmentId, files); - if (!etagsMatchRecord) { - return res.status(400).send('Failed to verify files on S3'); - } - - for (const file of files) { - const { filename, fileId, key } = file; - - try { - await models.trainingCertificates.addCertificate({ trainingRecordId, workerFk, filename, fileId, key }); - } catch (err) { - console.error(err); - return res.status(500).send('Failed to add records to database'); - } - } - - return res.status(200).send(); -}; - -const verifyEtagsForAllFiles = async (establishmentId, files) => { try { - for (const file of files) { - const etagMatchS3Record = await s3.verifyEtag(certificateBucket, file.key, file.etag); - if (!etagMatchS3Record) { - console.error('Etags in the request does not match the record at AWS bucket'); - return false; - } - } + await certificateService.confirmUpload(); + return res.status(200).send(); } catch (err) { - console.error(err); - return false; + return res.status(err.statusCode).send(err.message); } - return true; }; const getPresignedUrlForCertificateDownload = async (req, res) => { - const { filesToDownload } = req.body; - const { id, workerId, trainingUid } = req.params; - - if (!filesToDownload || !filesToDownload.length) { - return res.status(400).send('No files provided in request body'); - } - - const responsePayload = []; - - for (const file of filesToDownload) { - const signedUrl = await s3.getSignedUrlForDownload({ - bucket: certificateBucket, - key: makeFileKey(id, workerId, trainingUid, file.uid), - options: { expiresIn: downloadSignedUrlExpire }, - }); - responsePayload.push({ signedUrl, filename: file.filename }); - } + const certificateService = initialiseCertificateService(); - return res.status(200).json({ files: responsePayload }); -}; + const request = formatRequest(req); -const deleteRecordsFromDatabase = async (uids) => { try { - await models.trainingCertificates.deleteCertificate(uids); - return true; - } catch (error) { - console.log(error); - return false; - } -}; - -const deleteCertificatesFromS3 = async (filesToDeleteFromS3) => { - const deleteFromS3Response = await s3.deleteCertificatesFromS3({ - bucket: certificateBucket, - objects: filesToDeleteFromS3, - }); - - if (deleteFromS3Response?.Errors?.length > 0) { - console.error(JSON.stringify(deleteFromS3Response.Errors)); + const responsePayload = await certificateService.getPresignedUrlForCertificateDownload(request); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return res.status(err.statusCode).send(err.message); } }; const deleteCertificates = async (req, res) => { - const { filesToDelete } = req.body; - const { id, workerId, trainingUid } = req.params; - - if (!filesToDelete || !filesToDelete.length) { - return res.status(400).send('No files provided in request body'); - } - - let filesToDeleteFromS3 = []; - let filesToDeleteFromDatabase = []; - - for (const file of filesToDelete) { - let fileKey = makeFileKey(id, workerId, trainingUid, file.uid); - - filesToDeleteFromDatabase.push(file.uid); - filesToDeleteFromS3.push({ Key: fileKey }); - } + const certificateService = initialiseCertificateService(); + const request = formatRequest(req); try { - const noOfFilesFoundInDatabase = await models.trainingCertificates.countCertificatesToBeDeleted( - filesToDeleteFromDatabase, - ); - - if (noOfFilesFoundInDatabase !== filesToDeleteFromDatabase.length) { - return res.status(400).send('Invalid request'); - } - - const deletionFromDatabase = await deleteRecordsFromDatabase(filesToDeleteFromDatabase); - if (!deletionFromDatabase) { - return res.status(500).send(); - } - } catch (error) { - console.log(error); - return res.status(500).send(); + await certificateService.deleteCertificates(request); + return res.status(200).send(); + } catch (err) { + return res.status(err.statusCode).send(err.message); } - - await deleteCertificatesFromS3(filesToDeleteFromS3); - - return res.status(200).send(); }; router.route('/').post(hasPermission('canEditWorker'), requestUploadUrl); @@ -195,7 +86,4 @@ module.exports = router; module.exports.requestUploadUrl = requestUploadUrl; module.exports.confirmUpload = confirmUpload; module.exports.getPresignedUrlForCertificateDownload = getPresignedUrlForCertificateDownload; -module.exports.deleteCertificates = deleteCertificates; -module.exports.deleteRecordsFromDatabase = deleteRecordsFromDatabase; -module.exports.deleteCertificatesFromS3 = deleteCertificatesFromS3; -module.exports.makeFileKey = makeFileKey; +module.exports.deleteCertificates = deleteCertificates; \ No newline at end of file diff --git a/backend/server/routes/establishments/workerCertificate/workerCertificateService.js b/backend/server/routes/establishments/workerCertificate/workerCertificateService.js new file mode 100644 index 0000000000..f1395667e7 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/workerCertificateService.js @@ -0,0 +1,191 @@ +const { v4: uuidv4 } = require('uuid'); + +const config = require('../../../config/config'); +const models = require('../../../models'); + +const s3 = require('./s3'); + +const certificateBucket = String(config.get('workerCertificate.bucketname')); +const uploadSignedUrlExpire = config.get('workerCertificate.uploadSignedUrlExpire'); +const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlExpire'); +const HttpError = require('../../../utils/errors/httpError'); + +export class WorkerCertificateService { + certificatesModel; + certificateTypeModel; + recordType; + + constructor(certificatesModel, certificateTypeModel, certificateType) { + this.certificatesModel = certificatesModel; + this.certificateTypeModel = certificateTypeModel; + this.recordType = this.recordType; + } + + makeFileKey = (establishmentUid, workerId, recordUid, fileId) => { + return `${establishmentUid}/${workerId}/${this.recordType}Certificate/${recordUid}/${fileId}`; + }; + + requestUploadUrl = async ({files, params}) => { + const { files } = body; + const { id, workerId, recordUid } = params; + if (!files || !files.length) { + throw new HttpError('Missing `files` param in request body', 400); + } + + if (!files.every((file) => file?.filename)) { + throw new HttpError('Missing file name in request body', 400); + } + + const responsePayload = []; + + for (const file of files) { + const filename = file.filename; + const fileId = uuidv4(); + const key = makeFileKey(id, workerId, recordUid, fileId); + const signedUrl = await s3.getSignedUrlForUpload({ + bucket: certificateBucket, + key, + options: { expiresIn: uploadSignedUrlExpire }, + }); + responsePayload.push({ filename, signedUrl, fileId, key }); + } + + return { files: responsePayload }; + }; + + confirmUpload = async (req) => { + const { establishmentId } = req; + const { recordUid } = req.params; + const { files } = req.body; + + if (!files || !files.length) { + throw new HttpError('Missing `files` param in request body', 400); + } + + const record = await this.certificateTypeModel.findOne({ + where: { + uid: recordUid, + }, + attributes: ['id', 'workerFk'], + }); + + if (!record) { + throw new HttpError(`Failed to find related ${recordType} record`, 400); + } + + const { workerFk, id } = record.dataValues; + + const etagsMatchRecord = await verifyEtagsForAllFiles(establishmentId, files); + if (!etagsMatchRecord) { + throw new HttpError('Failed to verify files on S3', 400); + } + + for (const file of files) { + const { filename, fileId, key } = file; + + try { + await this.certificatesModel.addCertificate({ id, workerFk, filename, fileId, key }); + } catch (err) { + console.error(err); + throw new HttpError('Failed to add records to database', 500); + } + } + }; + + verifyEtagsForAllFiles = async (establishmentId, files) => { + try { + for (const file of files) { + const etagMatchS3Record = await s3.verifyEtag(certificateBucket, file.key, file.etag); + if (!etagMatchS3Record) { + console.error('Etags in the request does not match the record at AWS bucket'); + return false; + } + } + } catch (err) { + console.error(err); + return false; + } + return true; + }; + + getPresignedUrlForCertificateDownload = async ({files, params}) => { + const { id, workerId, recordUid } = params; + + if (!files || !files.length) { + return res.status(400).send('No files provided in request body'); + } + + const responsePayload = []; + + for (const file of files) { + const signedUrl = await s3.getSignedUrlForDownload({ + bucket: certificateBucket, + key: makeFileKey(id, workerId, recordUid, file.uid), + options: { expiresIn: downloadSignedUrlExpire }, + }); + responsePayload.push({ signedUrl, filename: file.filename }); + } + + return responsePayload; + }; + + deleteRecordsFromDatabase = async (uids) => { + try { + await this.certificatesModel.deleteCertificate(uids); + return true; + } catch (error) { + console.log(error); + return false; + } + }; + + deleteCertificatesFromS3 = async (filesToDeleteFromS3) => { + const deleteFromS3Response = await s3.deleteCertificatesFromS3({ + bucket: certificateBucket, + objects: filesToDeleteFromS3, + }); + + if (deleteFromS3Response?.Errors?.length > 0) { + console.error(JSON.stringify(deleteFromS3Response.Errors)); + } + }; + + deleteCertificates = async (req, res) => { + const { filesToDelete } = req.body; + const { id, workerId, recordUid } = req.params; + + if (!filesToDelete || !filesToDelete.length) { + throw new HttpError('No files provided in request body', 400); + } + + let filesToDeleteFromS3 = []; + let filesToDeleteFromDatabase = []; + + for (const file of filesToDelete) { + let fileKey = makeFileKey(id, workerId, recordUid, file.uid); + + filesToDeleteFromDatabase.push(file.uid); + filesToDeleteFromS3.push({ Key: fileKey }); + } + + try { + const noOfFilesFoundInDatabase = await this.certificatesModel.countCertificatesToBeDeleted( + filesToDeleteFromDatabase, + ); + + if (noOfFilesFoundInDatabase !== filesToDeleteFromDatabase.length) { + throw new HttpError('Invalid request', 400); + } + + const deletionFromDatabase = await deleteRecordsFromDatabase(filesToDeleteFromDatabase); + if (!deletionFromDatabase) { + throw new HttpError(undefined, 500); + } + } catch (error) { + console.log(error); + throw new HttpError(undefined, 500); + } + + await deleteCertificatesFromS3(filesToDeleteFromS3); + }; +} \ No newline at end of file From d3743349748a93826278a3c455b371c2ccb08b50 Mon Sep 17 00:00:00 2001 From: Jonathan Hardy Date: Thu, 10 Oct 2024 14:56:08 +0100 Subject: [PATCH 008/100] added new quals cert service --- .../models/qualificationCertificates.js | 99 +++++++++++++++++++ .../establishments/qualification/index.js | 2 + .../qualificationCertificate.js | 81 +++++++++++++++ .../workerCertificate/trainingCertificate.js | 8 -- 4 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 backend/server/models/qualificationCertificates.js create mode 100644 backend/server/routes/establishments/workerCertificate/qualificationCertificate.js diff --git a/backend/server/models/qualificationCertificates.js b/backend/server/models/qualificationCertificates.js new file mode 100644 index 0000000000..7fd976d9be --- /dev/null +++ b/backend/server/models/qualificationCertificates.js @@ -0,0 +1,99 @@ +/* jshint indent: 2 */ +const dayjs = require('dayjs'); + +module.exports = function (sequelize, DataTypes) { + const QualificationCertificates = sequelize.define( + 'qualificationCertificates', + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + field: '"ID"', + }, + uid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: '"UID"', + }, + workerFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerFK"', + }, + workerQualificationsFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerQualificationsFK"', + }, + filename: { + type: DataTypes.TEXT, + allowNull: true, + field: '"FileName"', + }, + uploadDate: { + type: DataTypes.DATE, + allowNull: false, + field: '"UploadDate"', + }, + key: { + type: DataTypes.TEXT, + allowNull: false, + field: '"Key"', + }, + }, + { + tableName: 'QualificationCertificates', + schema: 'cqc', + createdAt: false, + updatedAt: false, + }, + ); + + QualificationCertificates.associate = (models) => { + QualificationCertificates.belongsTo(models.worker, { + foreignKey: 'workerFk', + targetKey: 'id', + as: 'worker', + }); + + QualificationCertificates.belongsTo(models.workerQualifications, { + foreignKey: 'workerQualificationsFk', + targetKey: 'id', + as: 'workerQualifications', + }); + }; + + QualificationCertificates.addCertificate = function ({ qualificationRecordId, workerFk, filename, fileId, key }) { + const timeNow = dayjs().format(); + + return this.create({ + uid: fileId, + workerFk: workerFk, + workerQualificationsFk: qualificationRecordId, + filename: filename, + uploadDate: timeNow, + key, + }); + }; + + QualificationCertificates.deleteCertificate = async function (uids) { + return await this.destroy({ + where: { + uid: uids, + }, + }); + }; + + QualificationCertificates.countCertificatesToBeDeleted = async function (uids) { + return await this.count({ + where: { + uid: uids, + }, + }); + }; + + return QualificationsCertificates; +}; diff --git a/backend/server/routes/establishments/qualification/index.js b/backend/server/routes/establishments/qualification/index.js index 71a738f692..8b2f6dc45e 100644 --- a/backend/server/routes/establishments/qualification/index.js +++ b/backend/server/routes/establishments/qualification/index.js @@ -1,6 +1,7 @@ // default route for Workers' qualification endpoint const express = require('express'); const router = express.Router({ mergeParams: true }); +const QualificationCertificateRoute = require('../workerCertificate/qualificationCertificate'); // all user functionality is encapsulated const Qualification = require('../../../models/classes/qualification').Qualification; @@ -197,5 +198,6 @@ router.route('/available').get(hasPermission('canViewWorker'), availableQualific router.route('/:qualificationUid').get(hasPermission('canViewWorker'), viewQualification); router.route('/:qualificationUid').put(hasPermission('canEditWorker'), updateQualification); router.route('/:qualificationUid').delete(hasPermission('canEditWorker'), deleteQualification); +router.use('/:trainingUid/certificate', QualificationCertificateRoute); module.exports = router; diff --git a/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js b/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js new file mode 100644 index 0000000000..1b5ac62b33 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js @@ -0,0 +1,81 @@ +const express = require('express'); + +const models = require('../../../models'); + +const { hasPermission } = require('../../../utils/security/hasPermission'); +const router = express.Router({ mergeParams: true }); + +const initialiseCertificateService = () => { + return new WorkerCertificateService(models.qualificationCertificates, models.workerQualifications, 'qualification'); +} + +const formatRequest = (req) => { + return { + files: req.body, + params: { + id: req.params.id, + workerId: req.params.workerId, + recordUid: req.params.trainingUid + } + }; +} + +const requestUploadUrl = async (req, res) => { + const certificateService = initialiseCertificateService(); + + const request = formatRequest(req); + + try { + const responsePayload = await certificateService.requestUploadUrl(request); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return res.status(err.statusCode).send(err.message); + } +}; + +const confirmUpload = async (req, res) => { + const certificateService = initialiseCertificateService(); + + try { + await certificateService.confirmUpload(); + return res.status(200).send(); + } catch (err) { + return res.status(err.statusCode).send(err.message); + } +}; + +const getPresignedUrlForCertificateDownload = async (req, res) => { + const certificateService = initialiseCertificateService(); + + const request = formatRequest(req); + + try { + const responsePayload = await certificateService.getPresignedUrlForCertificateDownload(request); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return res.status(err.statusCode).send(err.message); + } +}; + +const deleteCertificates = async (req, res) => { + const certificateService = initialiseCertificateService(); + const request = formatRequest(req); + + try { + await certificateService.deleteCertificates(request); + return res.status(200).send(); + } catch (err) { + return res.status(err.statusCode).send(err.message); + } +}; + +router.route('/').post(hasPermission('canEditWorker'), requestUploadUrl); +router.route('/').put(hasPermission('canEditWorker'), confirmUpload); +router.route('/download').post(hasPermission('canEditWorker'), getPresignedUrlForCertificateDownload); +router.route('/delete').post(hasPermission('canEditWorker'), deleteCertificates); + +module.exports = router; +module.exports.requestUploadUrl = requestUploadUrl; +module.exports.confirmUpload = confirmUpload; +module.exports.getPresignedUrlForCertificateDownload = getPresignedUrlForCertificateDownload; +module.exports.deleteCertificates = deleteCertificates; \ No newline at end of file diff --git a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js index 951a08cc70..ef014bb9c1 100644 --- a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js +++ b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js @@ -1,16 +1,8 @@ -const { v4: uuidv4 } = require('uuid'); const express = require('express'); -const config = require('../../../config/config'); const models = require('../../../models'); -const s3 = require('./s3'); const { hasPermission } = require('../../../utils/security/hasPermission'); - -const certificateBucket = String(config.get('workerCertificate.bucketname')); -const uploadSignedUrlExpire = config.get('workerCertificate.uploadSignedUrlExpire'); -const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlExpire'); - const router = express.Router({ mergeParams: true }); const initialiseCertificateService = () => { From 075a78abfa3c9f95ae5803e7aa5ac8bcae7824ed Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 09:40:51 +0100 Subject: [PATCH 009/100] start adding certificate service to frontend --- .../src/app/core/model/qualification.model.ts | 5 +- .../core/services/certificate.service.spec.ts | 0 .../app/core/services/certificate.service.ts | 62 ++++ .../src/app/core/services/worker.service.ts | 2 +- .../test-utils/MockCertificationService.ts | 8 + .../add-edit-qualification.component.html | 3 +- .../add-edit-qualification.component.spec.ts | 291 ++++++++++++++---- .../add-edit-qualification.component.ts | 109 ++++++- .../app/features/workers/workers.module.ts | 3 + 9 files changed, 399 insertions(+), 84 deletions(-) create mode 100644 frontend/src/app/core/services/certificate.service.spec.ts create mode 100644 frontend/src/app/core/services/certificate.service.ts create mode 100644 frontend/src/app/core/test-utils/MockCertificationService.ts diff --git a/frontend/src/app/core/model/qualification.model.ts b/frontend/src/app/core/model/qualification.model.ts index fc5f92d8e2..fe38dc8ffe 100644 --- a/frontend/src/app/core/model/qualification.model.ts +++ b/frontend/src/app/core/model/qualification.model.ts @@ -46,8 +46,9 @@ export interface QualificationResponse { updated: string; updatedBy: string; qualification: Qualification; - year: number; - notes: string; + year?: number; + notes?: string; + qualificationCertificates?: QualificationCertificate[]; } export interface Qualification { diff --git a/frontend/src/app/core/services/certificate.service.spec.ts b/frontend/src/app/core/services/certificate.service.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/core/services/certificate.service.ts b/frontend/src/app/core/services/certificate.service.ts new file mode 100644 index 0000000000..4ce1f275a1 --- /dev/null +++ b/frontend/src/app/core/services/certificate.service.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { CertificateDownload } from '@core/model/training.model'; +import { Certificate } from '@core/model/trainingAndQualifications.model'; +import { Observable, of, throwError } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class BaseCertificateService { + constructor(private http: HttpClient) { + if (this.constructor == BaseCertificateService) { + throw new Error("Abstract base class can't be instantiated."); + } + } + + protected getBaseEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { + throw new Error('Not implemented for base class'); + } + + public addCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToUpload: File[], + ): Observable { + return of(null); + } + + public downloadCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], + ): Observable { + return of(null); + } + + public deleteCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDelete: Certificate[], + ): Observable { + return of(null); + } +} + +@Injectable() +export class TrainingCertificateService extends BaseCertificateService { + protected getBaseEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { + return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`; + } +} + +@Injectable() +export class QualificationCertificateService extends BaseCertificateService { + protected getBaseEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { + return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/qualification/${qualificationUid}/certificate`; + } +} diff --git a/frontend/src/app/core/services/worker.service.ts b/frontend/src/app/core/services/worker.service.ts index 79c67bd49e..158456d339 100644 --- a/frontend/src/app/core/services/worker.service.ts +++ b/frontend/src/app/core/services/worker.service.ts @@ -210,7 +210,7 @@ export class WorkerService { } createQualification(workplaceUid: string, workerId: string, record: QualificationRequest) { - return this.http.post( + return this.http.post( `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerId}/qualification`, record, ); diff --git a/frontend/src/app/core/test-utils/MockCertificationService.ts b/frontend/src/app/core/test-utils/MockCertificationService.ts new file mode 100644 index 0000000000..5d498dfc4b --- /dev/null +++ b/frontend/src/app/core/test-utils/MockCertificationService.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; + +@Injectable() +export class MockTrainingCertificateService extends TrainingCertificateService {} + +@Injectable() +export class MockQualificationCertificateService extends QualificationCertificateService {} diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 71767d9814..293caf3f7c 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -78,11 +78,12 @@

Type: {{ qualificationType }}

diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index 8f419a653d..830e6a9353 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -14,12 +14,14 @@ import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; import { fireEvent, render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AddEditQualificationComponent } from './add-edit-qualification.component'; +import { QualificationCertificateService } from '@core/services/certificate.service'; +import { MockQualificationCertificateService } from '@core/test-utils/MockCertificationService'; fdescribe('AddEditQualificationComponent', () => { - async function setup(qualificationId = '1', qualificationInService = null) { + async function setup(qualificationId = '1', qualificationInService = null, override: any = {}) { const { fixture, getByText, getByTestId, queryByText, queryByTestId, getByLabelText, getAllByText } = await render( AddEditQualificationComponent, { @@ -51,6 +53,10 @@ fdescribe('AddEditQualificationComponent', () => { clearSelectedQualification: () => {}, }, }, + { + provide: QualificationCertificateService, + useClass: MockQualificationCertificateService, + }, ], }, ); @@ -61,6 +67,7 @@ fdescribe('AddEditQualificationComponent', () => { const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); const workerService = injector.inject(WorkerService) as WorkerService; const qualificationService = injector.inject(QualificationService) as QualificationService; + const certificateService = injector.inject(QualificationCertificateService) as QualificationCertificateService; return { component, @@ -74,9 +81,44 @@ fdescribe('AddEditQualificationComponent', () => { getAllByText, workerService, qualificationService, + certificateService, }; } + const mockQualificationData = { + created: '2024-10-01T08:53:35.143Z', + notes: 'ihoihio', + qualification: { + group: 'Degree', + id: 136, + level: '6', + title: 'Health and social care degree (level 6)', + }, + + uid: 'fd50276b-e27c-48a6-9015-f0c489302666', + updated: '2024-10-01T08:53:35.143Z', + updatedBy: 'duncan', + year: 1999, + } as QualificationResponse; + + const setupWithExistingQualification = async (override: any = {}) => { + const setupOutputs = await setup('mockQualificationId'); + + const { component, workerService, fixture } = setupOutputs; + const { qualificationCertificates } = override; + const mockGetQualificationResponse: QualificationResponse = qualificationCertificates + ? { ...mockQualificationData, qualificationCertificates } + : mockQualificationData; + + spyOn(workerService, 'getQualification').and.returnValue(of(mockGetQualificationResponse)); + const updateQualificationSpy = spyOn(workerService, 'updateQualification').and.returnValue(of(null)); + + component.ngOnInit(); + fixture.detectChanges(); + + return { ...setupOutputs, updateQualificationSpy }; + }; + it('should create', async () => { const { component } = await setup(); expect(component).toBeTruthy(); @@ -277,6 +319,70 @@ fdescribe('AddEditQualificationComponent', () => { expect(component.filesToUpload).toHaveSize(1); expect(component.filesToUpload[0]).toEqual(mockUploadFile2); }); + + it('should call certificateService with the selected files on form submit (for new qualification)', async () => { + const mockNewQualificationResponse = { + uid: 'newQualificationUid', + qualification: mockQualification, + created: '', + updated: '', + updatedBy: '', + }; + + const { component, fixture, getByTestId, getByText, certificateService, workerService } = await setup( + null, + mockQualification, + ); + + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of('null')); + spyOn(workerService, 'createQualification').and.returnValue(of(mockNewQualificationResponse)); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + userEvent.click(getByText('Save record')); + + expect(addCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + mockNewQualificationResponse.uid, + [mockUploadFile1, mockUploadFile2], + ); + }); + + it('should call certificateService with the selected files on form submit (for existing qualification)', async () => { + const mockGetQualificationResponse = { + uid: 'mockQualificationUid', + qualification: mockQualification, + created: '', + updated: '', + updatedBy: '', + }; + + const { component, fixture, getByTestId, getByText, certificateService, workerService } = await setup( + 'mockQualificationUid', + ); + + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of('null')); + spyOn(workerService, 'getQualification').and.returnValue(of(mockGetQualificationResponse)); + spyOn(workerService, 'updateQualification').and.returnValue(of(null)); + + component.ngOnInit(); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + userEvent.click(getByText('Save and return')); + + expect(addCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [mockUploadFile1, mockUploadFile2], + ); + }); }); describe('saved certificates', () => { @@ -298,30 +404,23 @@ fdescribe('AddEditQualificationComponent', () => { }, ]; - it('should show the table when there are certificates', async () => { - const { component, fixture, getByTestId } = await setup(); + const setupWithSavedCertificates = () => + setupWithExistingQualification({ qualificationCertificates: savedCertificates }); - component.qualificationCertificates = savedCertificates; - fixture.detectChanges(); + it('should show the table when there are certificates', async () => { + const { getByTestId } = await setupWithSavedCertificates(); expect(getByTestId('qualificationCertificatesTable')).toBeTruthy(); }); it('should not show the table when there are no certificates', async () => { - const { component, fixture, queryByTestId } = await setup(); - - component.qualificationCertificates = []; - - fixture.detectChanges(); + const { queryByTestId } = await setup('mockQualificationId'); expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); }); it('should display a row for each certificate', async () => { - const { component, fixture, getByTestId } = await setup(); - component.qualificationCertificates = savedCertificates; - - fixture.detectChanges(); + const { getByTestId } = await setupWithSavedCertificates(); savedCertificates.forEach((certificate, index) => { const certificateRow = getByTestId(`certificate-row-${index}`); @@ -332,36 +431,130 @@ fdescribe('AddEditQualificationComponent', () => { }); }); - it('should remove a file from the table when the remove button is clicked', async () => { - const { component, fixture, getByTestId, queryByText } = await setup(); + describe('download certificates', () => { + it('should make call to downloadCertificates with required uids and file uid in array when Download button clicked', async () => { + const { component, fixture, getByTestId, certificateService } = await setupWithSavedCertificates(); - component.qualificationCertificates = savedCertificates; + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue(of(null)); - fixture.detectChanges(); + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const firstCertDownloadButton = within(certificatesTable).getAllByText('Download')[0]; + firstCertDownloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [{ uid: savedCertificates[0].uid, filename: savedCertificates[0].filename }], + ); + }); - const certificateRow2 = getByTestId('certificate-row-2'); - const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + it('should make call to downloadCertificates with all certificate file uids in array when Download all button clicked', async () => { + const { component, fixture, getByTestId, certificateService } = await setupWithSavedCertificates(); + + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( + of({ files: ['abc123'] }), + ); + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadButton = within(certificatesTable).getByText('Download all'); + downloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [ + { uid: savedCertificates[0].uid, filename: savedCertificates[0].filename }, + { uid: savedCertificates[1].uid, filename: savedCertificates[1].filename }, + { uid: savedCertificates[2].uid, filename: savedCertificates[2].filename }, + ], + ); + }); - userEvent.click(removeButtonForRow2); - fixture.detectChanges(); + it('should display error message when Download fails', async () => { + const { component, fixture, getByText, getByTestId, certificateService } = await setupWithSavedCertificates(); + + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + component.qualificationCertificates = savedCertificates; + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadButton = within(certificatesTable).getAllByText('Download')[1]; + downloadButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); + + it('should display error message when Download all fails', async () => { + const { fixture, getByText, getByTestId, certificateService } = await setupWithSavedCertificates(); + + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('some download error')); - expect(component.qualificationCertificates.length).toBe(2); - expect(queryByText(savedCertificates[2].filename)).toBeFalsy(); + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadAllButton = within(certificatesTable).getByText('Download all'); + downloadAllButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); }); - it('should not show the table when all files are removed', async () => { - const { component, fixture, getByText, queryByTestId } = await setup(); - component.qualificationCertificates = savedCertificates; + describe('remove certificates', () => { + it('should remove a file from the table when the remove button is clicked', async () => { + const { component, fixture, getByTestId, queryByText } = await setupWithSavedCertificates(); - fixture.autoDetectChanges(); + fixture.detectChanges(); + + const certificateRow2 = getByTestId('certificate-row-2'); + const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + + userEvent.click(removeButtonForRow2); + fixture.detectChanges(); - savedCertificates.forEach((certificate) => { - const certificateRow = getByText(certificate.filename).parentElement; - const removeButton = within(certificateRow).getByText('Remove'); - userEvent.click(removeButton); + expect(component.qualificationCertificates.length).toBe(2); + expect(queryByText(savedCertificates[2].filename)).toBeFalsy(); }); - expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + it('should not show the table when all files are removed', async () => { + const { fixture, getByText, queryByTestId } = await setupWithSavedCertificates(); + + fixture.autoDetectChanges(); + + savedCertificates.forEach((certificate) => { + const certificateRow = getByText(certificate.filename).parentElement; + const removeButton = within(certificateRow).getByText('Remove'); + userEvent.click(removeButton); + }); + + expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + }); + + it('should call certificateService with the files to be removed', async () => { + const { component, fixture, getByTestId, getByText, certificateService } = await setupWithSavedCertificates(); + fixture.autoDetectChanges(); + + const deleteCertificatesSpy = spyOn(certificateService, 'deleteCertificates').and.returnValue(of(null)); + + const certificateRow1 = getByTestId('certificate-row-1'); + const removeButtonForRow1 = within(certificateRow1).getByText('Remove'); + + userEvent.click(removeButtonForRow1); + userEvent.click(getByText('Save and return')); + + expect(deleteCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + 'mockQualificationId', + [savedCertificates[1]], + ); + }); }); }); }); @@ -450,36 +643,6 @@ fdescribe('AddEditQualificationComponent', () => { }); describe('prefilling data for existing qualification', () => { - const mockQualificationData = { - created: '2024-10-01T08:53:35.143Z', - notes: 'ihoihio', - qualification: { - group: 'Degree', - id: 136, - level: '6', - title: 'Health and social care degree (level 6)', - }, - - uid: 'fd50276b-e27c-48a6-9015-f0c489302666', - updated: '2024-10-01T08:53:35.143Z', - updatedBy: 'duncan', - year: 1999, - } as QualificationResponse; - - const setupWithExistingQualification = async () => { - const { component, workerService, fixture, getByText, queryByText, getByTestId } = await setup( - 'mockQualificationId', - ); - - spyOn(workerService, 'getQualification').and.returnValue(of(mockQualificationData)); - const updateQualificationSpy = spyOn(workerService, 'updateQualification').and.returnValue(of(null)); - - component.ngOnInit(); - fixture.detectChanges(); - - return { component, workerService, fixture, getByText, queryByText, updateQualificationSpy, getByTestId }; - }; - it('should display qualification title and lower case group', async () => { const { getByText } = await setupWithExistingQualification(); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts index 0e56a4ac59..953d47c94f 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts @@ -1,3 +1,8 @@ +import dayjs from 'dayjs'; +import { Subscription } from 'rxjs'; +import { Observable } from 'rxjs-compat'; +import { mergeMap } from 'rxjs/operators'; + import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -11,15 +16,16 @@ import { QualificationResponse, QualificationType, } from '@core/model/qualification.model'; +import { CertificateDownload } from '@core/model/training.model'; +import { Certificate } from '@core/model/trainingAndQualifications.model'; import { Worker } from '@core/model/worker.model'; import { BackLinkService } from '@core/services/backLink.service'; +import { QualificationCertificateService } from '@core/services/certificate.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { QualificationService } from '@core/services/qualification.service'; import { TrainingService } from '@core/services/training.service'; import { WorkerService } from '@core/services/worker.service'; import { CustomValidators } from '@shared/validators/custom-form-validators'; -import dayjs from 'dayjs'; -import { Subscription } from 'rxjs'; @Component({ selector: 'app-add-edit-qualification', @@ -62,6 +68,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { private workerService: WorkerService, private backLinkService: BackLinkService, private qualificationService: QualificationService, + private certificateService: QualificationCertificateService, ) { this.yearValidators = [Validators.max(dayjs().year()), Validators.min(dayjs().subtract(100, 'years').year())]; } @@ -101,6 +108,27 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { this.notesOpen = true; this.remainingCharacterCount = this.notesMaxLength - this.record.notes.length; } + + if (this.record.qualificationCertificates) { + this.qualificationCertificates = this.record.qualificationCertificates; + } + + // add temporary mock data to check frontend appearance. + // to be removed when getQualification endpoint returns with the certs. + // if (this.record.qualificationCertificates === undefined) { + // this.qualificationCertificates = [ + // { + // uid: 'uid1', + // filename: 'certificate 2023.pdf', + // uploadDate: '2023-07-01T10:24:31Z', + // }, + // { + // uid: 'uid2', + // filename: 'certificate 2024.pdf', + // uploadDate: '2024-05-01T12:34:56Z', + // }, + // ]; + // } } }, (error) => { @@ -190,23 +218,64 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { notes, }; - if (this.qualificationId) { - this.subscriptions.add( - this.workerService - .updateQualification(this.workplace.uid, this.worker.uid, this.qualificationId, record) - .subscribe( - () => this.onSuccess(), - (error) => this.onError(error), - ), + let submitQualificationRecord: Observable = this.qualificationId + ? this.workerService.updateQualification(this.workplace.uid, this.worker.uid, this.qualificationId, record) + : this.workerService.createQualification(this.workplace.uid, this.worker.uid, record); + + if (this.filesToUpload?.length > 0) { + submitQualificationRecord = submitQualificationRecord.pipe( + mergeMap((response) => this.uploadNewCertificate(response)), ); - } else { - this.subscriptions.add( - this.workerService.createQualification(this.workplace.uid, this.worker.uid, record).subscribe( - () => this.onSuccess(), - (error) => this.onError(error), + } + + if (this.filesToRemove?.length > 0) { + this.deleteQualificationCertificate(this.filesToRemove); + } + + this.subscriptions.add( + submitQualificationRecord.subscribe( + () => this.onSuccess(), + (error) => this.onError(error), + ), + ); + } + + private uploadNewCertificate(response: QualificationResponse): Observable { + const qualifcationId = this.qualificationId ? this.qualificationId : response.uid; + return this.certificateService.addCertificates( + this.workplace.uid, + this.worker.uid, + qualifcationId, + this.filesToUpload, + ); + } + + public downloadCertificates(fileIndex: number | null): void { + const filesToDownload = this.getFilesToDownload(fileIndex); + + this.subscriptions.add( + this.certificateService + .downloadCertificates(this.workplace.uid, this.worker.uid, this.qualificationId, filesToDownload) + .subscribe( + () => { + this.certificateErrors = []; + }, + (_error) => { + this.certificateErrors = ["There's a problem with this download. Try again later or contact us for help."]; + }, ), - ); + ); + } + + private getFilesToDownload(fileIndex: number | null): CertificateDownload[] { + if (fileIndex !== null) { + return [this.qualificationCertificates[fileIndex]].map(this.formatForCertificateDownload); } + return this.qualificationCertificates.map(this.formatForCertificateDownload); + } + + private formatForCertificateDownload(certificate: Certificate): CertificateDownload { + return { uid: certificate.uid, filename: certificate.filename }; } private onSuccess(): void { @@ -274,6 +343,14 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { ); } + private deleteQualificationCertificate(files: QualificationCertificate[]) { + this.subscriptions.add( + this.certificateService + .deleteCertificates(this.workplace.uid, this.worker.uid, this.qualificationId, files) + .subscribe(), + ); + } + protected navigateToDeleteQualificationRecord(): void { this.router.navigate([ '/workplace', diff --git a/frontend/src/app/features/workers/workers.module.ts b/frontend/src/app/features/workers/workers.module.ts index 723532b32c..428e853e79 100644 --- a/frontend/src/app/features/workers/workers.module.ts +++ b/frontend/src/app/features/workers/workers.module.ts @@ -68,6 +68,7 @@ import { TotalStaffChangeComponent } from './total-staff-change/total-staff-chan import { WeeklyContractedHoursComponent } from './weekly-contracted-hours/weekly-contracted-hours.component'; import { WorkersRoutingModule } from './workers-routing.module'; import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.component'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; @NgModule({ imports: [CommonModule, OverlayModule, FormsModule, ReactiveFormsModule, SharedModule, WorkersRoutingModule], @@ -138,6 +139,8 @@ import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.compon TrainingRecordsForCategoryResolver, MandatoryTrainingCategoriesResolver, AvailableQualificationsResolver, + TrainingCertificateService, + QualificationCertificateService, ], }) export class WorkersModule {} From c332a445192275ae748a2c6069d3250f5b23ad05 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 10:59:26 +0100 Subject: [PATCH 010/100] migrate certificate related method from trainingService to certficateService --- .../app/core/services/certificate.service.ts | 134 +++++++++++++-- .../src/app/core/services/training.service.ts | 153 +----------------- 2 files changed, 126 insertions(+), 161 deletions(-) diff --git a/frontend/src/app/core/services/certificate.service.ts b/frontend/src/app/core/services/certificate.service.ts index 4ce1f275a1..59201f2455 100644 --- a/frontend/src/app/core/services/certificate.service.ts +++ b/frontend/src/app/core/services/certificate.service.ts @@ -1,21 +1,31 @@ +import { forkJoin, from, Observable } from 'rxjs'; +import { map, mergeAll, mergeMap, tap } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; + import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { CertificateDownload } from '@core/model/training.model'; +import { + CertificateDownload, + ConfirmUploadRequest, + DownloadCertificateSignedUrlResponse, + FileInfoWithETag, + S3UploadResponse, + UploadCertificateSignedUrlRequest, + UploadCertificateSignedUrlResponse, +} from '@core/model/training.model'; import { Certificate } from '@core/model/trainingAndQualifications.model'; -import { Observable, of, throwError } from 'rxjs'; -import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root', }) export class BaseCertificateService { - constructor(private http: HttpClient) { + constructor(protected http: HttpClient) { if (this.constructor == BaseCertificateService) { throw new Error("Abstract base class can't be instantiated."); } } - protected getBaseEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { + protected getCertificateEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { throw new Error('Not implemented for base class'); } @@ -25,7 +35,62 @@ export class BaseCertificateService { recordUid: string, filesToUpload: File[], ): Observable { - return of(null); + const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); + const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; + const endpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + + return this.http.post(endpoint, requestBody).pipe( + mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), + map((allFileInfoWithETag) => this.buildConfirmUploadRequestBody(allFileInfoWithETag)), + mergeMap((confirmUploadRequestBody) => + this.confirmCertificateUpload(workplaceUid, workerUid, recordUid, confirmUploadRequestBody), + ), + ); + } + + protected uploadAllCertificatestoS3( + signedUrlResponse: UploadCertificateSignedUrlResponse, + filesToUpload: File[], + ): Observable { + const allUploadResults$ = signedUrlResponse.files.map(({ signedUrl, fileId, filename, key }, index) => { + const fileToUpload = filesToUpload[index]; + if (!fileToUpload.name || fileToUpload.name !== filename) { + throw new Error('Invalid response from backend'); + } + return this.uploadOneCertificateToS3(signedUrl, fileId, fileToUpload, key); + }); + + return forkJoin(allUploadResults$); + } + + protected uploadOneCertificateToS3( + signedUrl: string, + fileId: string, + uploadFile: File, + key: string, + ): Observable { + return this.http.put(signedUrl, uploadFile, { observe: 'response' }).pipe( + map((s3response) => ({ + etag: s3response?.headers?.get('etag'), + fileId, + filename: uploadFile.name, + key, + })), + ); + } + + protected buildConfirmUploadRequestBody(allFileInfoWithETag: FileInfoWithETag[]): ConfirmUploadRequest { + return { files: allFileInfoWithETag }; + } + + protected confirmCertificateUpload( + workplaceUid: string, + workerUid: string, + recordUid: string, + confirmUploadRequestBody: ConfirmUploadRequest, + ) { + const endpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.put(endpoint, confirmUploadRequestBody); } public downloadCertificates( @@ -34,7 +99,55 @@ export class BaseCertificateService { recordUid: string, filesToDownload: CertificateDownload[], ): Observable { - return of(null); + return this.getCertificateDownloadUrls(workplaceUid, workerUid, recordUid, filesToDownload).pipe( + mergeMap((res) => this.triggerCertificateDownloads(res['files'])), + ); + } + + public getCertificateDownloadUrls( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], + ) { + const certificateEndpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.post(`${certificateEndpoint}/download`, { filesToDownload }); + } + + public triggerCertificateDownloads(files: { signedUrl: string; filename: string }[]): Observable<{ + blob: Blob; + filename: string; + }> { + const downloadedBlobs = files.map((file) => this.http.get(file.signedUrl, { responseType: 'blob' })); + const blobsAndFilenames = downloadedBlobs.map((blob$, index) => + blob$.pipe(map((blob) => ({ blob, filename: files[index].filename }))), + ); + return from(blobsAndFilenames).pipe( + mergeAll(), + tap(({ blob, filename }) => this.triggerSingleCertificateDownload(blob, filename)), + ); + } + + private triggerSingleCertificateDownload(fileBlob: Blob, filename: string): void { + const blobUrl = window.URL.createObjectURL(fileBlob); + const link = this.createHiddenDownloadLink(blobUrl, filename); + + // Append the link to the body and click to trigger download + document.body.appendChild(link); + link.click(); + + // Remove the link + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + } + + private createHiddenDownloadLink(blobUrl: string, filename: string): HTMLAnchorElement { + const link = document.createElement('a'); + + link.href = blobUrl; + link.download = filename; + link.style.display = 'none'; + return link; } public deleteCertificates( @@ -43,20 +156,21 @@ export class BaseCertificateService { recordUid: string, filesToDelete: Certificate[], ): Observable { - return of(null); + const certificateEndpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.post(`${certificateEndpoint}/delete`, { filesToDelete }); } } @Injectable() export class TrainingCertificateService extends BaseCertificateService { - protected getBaseEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { + protected getCertificateEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`; } } @Injectable() export class QualificationCertificateService extends BaseCertificateService { - protected getBaseEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { + protected getCertificateEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/qualification/${qualificationUid}/certificate`; } } diff --git a/frontend/src/app/core/services/training.service.ts b/frontend/src/app/core/services/training.service.ts index db468023b8..c9e0635028 100644 --- a/frontend/src/app/core/services/training.service.ts +++ b/frontend/src/app/core/services/training.service.ts @@ -1,22 +1,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; -import { - allMandatoryTrainingCategories, - CertificateDownload, - UploadCertificateSignedUrlRequest, - UploadCertificateSignedUrlResponse, - ConfirmUploadRequest, - FileInfoWithETag, - S3UploadResponse, - SelectedTraining, - TrainingCategory, - DownloadCertificateSignedUrlResponse, - TrainingCertificate, -} from '@core/model/training.model'; +import { allMandatoryTrainingCategories, SelectedTraining, TrainingCategory } from '@core/model/training.model'; import { Worker } from '@core/model/worker.model'; -import { BehaviorSubject, forkJoin, from, Observable } from 'rxjs'; -import { map, mergeAll, mergeMap, tap } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; import { environment } from 'src/environments/environment'; @Injectable({ @@ -137,140 +124,4 @@ export class TrainingService { public clearUpdatingSelectedStaffForMultipleTraining(): void { this.updatingSelectedStaffForMultipleTraining = null; } - - public addCertificateToTraining(workplaceUid: string, workerUid: string, trainingUid: string, filesToUpload: File[]) { - const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); - const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; - - return this.http - .post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, - requestBody, - ) - .pipe( - mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), - map((allFileInfoWithETag) => this.buildConfirmUploadRequestBody(allFileInfoWithETag)), - mergeMap((confirmUploadRequestBody) => - this.confirmCertificateUpload(workplaceUid, workerUid, trainingUid, confirmUploadRequestBody), - ), - ); - } - - private uploadAllCertificatestoS3( - signedUrlResponse: UploadCertificateSignedUrlResponse, - filesToUpload: File[], - ): Observable { - const allUploadResults$ = signedUrlResponse.files.map(({ signedUrl, fileId, filename, key }, index) => { - const fileToUpload = filesToUpload[index]; - if (!fileToUpload.name || fileToUpload.name !== filename) { - throw new Error('Invalid response from backend'); - } - return this.uploadOneCertificateToS3(signedUrl, fileId, fileToUpload, key); - }); - - return forkJoin(allUploadResults$); - } - - private uploadOneCertificateToS3( - signedUrl: string, - fileId: string, - uploadFile: File, - key: string, - ): Observable { - return this.http.put(signedUrl, uploadFile, { observe: 'response' }).pipe( - map((s3response) => ({ - etag: s3response?.headers?.get('etag'), - fileId, - filename: uploadFile.name, - key, - })), - ); - } - - public downloadCertificates( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDownload: CertificateDownload[], - ): Observable { - return this.getCertificateDownloadUrls(workplaceUid, workerUid, trainingUid, filesToDownload).pipe( - mergeMap((res) => this.triggerCertificateDownloads(res['files'])), - ); - } - - public getCertificateDownloadUrls( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDownload: CertificateDownload[], - ) { - return this.http.post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/download`, - { filesToDownload }, - ); - } - - public triggerCertificateDownloads(files: { signedUrl: string; filename: string }[]): Observable<{ - blob: Blob; - filename: string; - }> { - const downloadedBlobs = files.map((file) => this.http.get(file.signedUrl, { responseType: 'blob' })); - const blobsAndFilenames = downloadedBlobs.map((blob$, index) => - blob$.pipe(map((blob) => ({ blob, filename: files[index].filename }))), - ); - return from(blobsAndFilenames).pipe( - mergeAll(), - tap(({ blob, filename }) => this.triggerSingleCertificateDownload(blob, filename)), - ); - } - - private triggerSingleCertificateDownload(fileBlob: Blob, filename: string): void { - const blobUrl = window.URL.createObjectURL(fileBlob); - const link = this.createHiddenDownloadLink(blobUrl, filename); - - // Append the link to the body and click to trigger download - document.body.appendChild(link); - link.click(); - - // Remove the link - document.body.removeChild(link); - window.URL.revokeObjectURL(blobUrl); - } - - private createHiddenDownloadLink(blobUrl: string, filename: string): HTMLAnchorElement { - const link = document.createElement('a'); - - link.href = blobUrl; - link.download = filename; - link.style.display = 'none'; - return link; - } - - private buildConfirmUploadRequestBody(allFileInfoWithETag: FileInfoWithETag[]): ConfirmUploadRequest { - return { files: allFileInfoWithETag }; - } - - private confirmCertificateUpload( - workplaceUid: string, - workerUid: string, - trainingUid: string, - confirmUploadRequestBody: ConfirmUploadRequest, - ) { - return this.http.put( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, - confirmUploadRequestBody, - ); - } - - public deleteCertificates( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDelete: TrainingCertificate[], - ): Observable { - return this.http.post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/delete`, - { filesToDelete }, - ); - } } From e0bc99db7d95d638907c1617435ee4d7df1eba43 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 11:00:00 +0100 Subject: [PATCH 011/100] migrate references for certificate related methods to CertificateService --- .../core/services/certificate.service.spec.ts | 243 ++++++++++++++++++ .../core/services/training.service.spec.ts | 201 --------------- .../add-edit-qualification.component.spec.ts | 2 +- .../add-edit-training.component.spec.ts | 68 ++--- .../add-edit-training.component.ts | 8 +- ...nd-qualifications-record.component.spec.ts | 50 ++-- ...ing-and-qualifications-record.component.ts | 12 +- 7 files changed, 323 insertions(+), 261 deletions(-) diff --git a/frontend/src/app/core/services/certificate.service.spec.ts b/frontend/src/app/core/services/certificate.service.spec.ts index e69de29bb2..bc8da39952 100644 --- a/frontend/src/app/core/services/certificate.service.spec.ts +++ b/frontend/src/app/core/services/certificate.service.spec.ts @@ -0,0 +1,243 @@ +import { environment } from 'src/environments/environment'; + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { QualificationCertificateService, TrainingCertificateService } from './certificate.service'; + +describe('CertificateService', () => { + const testConfigs = [ + { + certificateType: 'training', + serviceClass: TrainingCertificateService, + }, + { + certificateType: 'qualification', + serviceClass: QualificationCertificateService, + }, + ]; + testConfigs.forEach(({ certificateType, serviceClass }) => { + describe(`for Certificate type: ${certificateType}`, () => { + let service: TrainingCertificateService | QualificationCertificateService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [serviceClass], + }); + if (certificateType === 'training') { + service = TestBed.inject(TrainingCertificateService); + } else { + service = TestBed.inject(QualificationCertificateService); + } + + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + TestBed.inject(HttpTestingController).verify(); + }); + + describe('addCertificates', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + + const mockUploadFiles = [new File([''], 'certificate.pdf')]; + const mockFileId = 'mockFileId'; + const mockEtagFromS3 = 'mock-etag'; + const mockSignedUrl = 'http://localhost/mock-signed-url-for-upload'; + + const certificateEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate`; + + it('should call to backend to retreive a signed url to upload certificate', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + expect(signedUrlRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains filename', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + + const expectedRequestBody = { files: [{ filename: 'certificate.pdf' }] }; + + expect(signedUrlRequest.request.body).toEqual(expectedRequestBody); + }); + + it('should upload the file with the signed url received from backend', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[0]); + }); + + it('should call to backend to confirm upload complete', async () => { + const mockKey = 'abcd/adsadsadvfdv/123dsvf'; + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId, key: mockKey }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + uploadToS3Request.flush(null, { headers: { etag: mockEtagFromS3 } }); + + const confirmUploadRequest = http.expectOne(certificateEndpoint); + const expectedconfirmUploadReqBody = { + files: [{ filename: mockUploadFiles[0].name, fileId: mockFileId, etag: mockEtagFromS3, key: mockKey }], + }; + + expect(confirmUploadRequest.request.method).toBe('PUT'); + expect(confirmUploadRequest.request.body).toEqual(expectedconfirmUploadReqBody); + }); + + describe('multiple files upload', () => { + const mockUploadFilenames = ['certificate1.pdf', 'certificate2.pdf', 'certificate3.pdf']; + const mockUploadFiles = mockUploadFilenames.map((filename) => new File([''], filename)); + const mockFileIds = ['fileId1', 'fileId2', 'fileId3']; + const mockEtags = ['etag1', 'etag2', 'etag3']; + const mockSignedUrls = mockFileIds.map((fileId) => `${mockSignedUrl}/${fileId}`); + const mockKeys = mockFileIds.map((fileId) => `${fileId}/mockKey`); + + const mockSignedUrlResponse = { + files: mockUploadFilenames.map((filename, index) => ({ + filename, + signedUrl: mockSignedUrls[index], + fileId: mockFileIds[index], + key: mockKeys[index], + })), + }; + + const expectedSignedUrlReqBody = { + files: [ + { filename: mockUploadFiles[0].name }, + { filename: mockUploadFiles[1].name }, + { filename: mockUploadFiles[2].name }, + ], + }; + const expectedConfirmUploadReqBody = { + files: [ + { filename: mockUploadFiles[0].name, fileId: mockFileIds[0], etag: mockEtags[0], key: mockKeys[0] }, + { filename: mockUploadFiles[1].name, fileId: mockFileIds[1], etag: mockEtags[1], key: mockKeys[1] }, + { filename: mockUploadFiles[2].name, fileId: mockFileIds[2], etag: mockEtags[2], key: mockKeys[2] }, + ], + }; + + it('should be able to upload multiple files at the same time', () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne({ method: 'POST', url: certificateEndpoint }); + expect(signedUrlRequest.request.body).toEqual(expectedSignedUrlReqBody); + + signedUrlRequest.flush(mockSignedUrlResponse); + + mockSignedUrls.forEach((signedUrl, index) => { + const uploadToS3Request = http.expectOne(signedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[index]); + + uploadToS3Request.flush(null, { headers: { etag: mockEtags[index] } }); + }); + + const confirmUploadRequest = http.expectOne({ method: 'PUT', url: certificateEndpoint }); + expect(confirmUploadRequest.request.body).toEqual(expectedConfirmUploadReqBody); + }); + }); + }); + + describe('downloadCertificates', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + + const mockFiles = [{ uid: 'mockCertificateUid123', filename: 'mockCertificateName' }]; + + const certificateDownloadEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate/download`; + + it('should make call to expected backend endpoint', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + expect(downloadRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains file uid', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + const expectedRequestBody = { filesToDownload: mockFiles }; + + expect(downloadRequest.request.body).toEqual(expectedRequestBody); + }); + }); + + describe('triggerCertificateDownloads', () => { + it('should download certificates by creating and triggering anchor tag, then cleaning DOM', () => { + const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; + const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); + const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); + const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); + const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); + spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); + + service.triggerCertificateDownloads(mockCertificates).subscribe(); + + const downloadReq = http.expectOne(mockCertificates[0].signedUrl); + downloadReq.flush(mockBlob); + + // Assert anchor element appended + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalled(); + + // Assert anchor element has correct attributes + const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; + expect(createdAnchor.href).toBe(mockBlobUrl); + expect(createdAnchor.download).toBe(mockCertificates[0].filename); + + // Assert DOM is cleaned up after download + expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); + + describe('deleteCertificates', () => { + it('should call the endpoint for deleting training certificates', async () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + const mockFilesToDelete = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-09-23T11:02:10.000Z', + }, + ]; + + const deleteCertificatesEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate/delete`; + + service.deleteCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFilesToDelete).subscribe(); + + const deleteRequest = http.expectOne(deleteCertificatesEndpoint); + const expectedRequestBody = { filesToDelete: mockFilesToDelete }; + + expect(deleteRequest.request.method).toBe('POST'); + expect(deleteRequest.request.body).toEqual(expectedRequestBody); + }); + }); + }); + }); +}); diff --git a/frontend/src/app/core/services/training.service.spec.ts b/frontend/src/app/core/services/training.service.spec.ts index 9a58115e2e..71d41615e5 100644 --- a/frontend/src/app/core/services/training.service.spec.ts +++ b/frontend/src/app/core/services/training.service.spec.ts @@ -2,7 +2,6 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { TestBed } from '@angular/core/testing'; import { environment } from 'src/environments/environment'; import { TrainingService } from './training.service'; -import { TrainingCertificate } from '@core/model/training.model'; describe('TrainingService', () => { let service: TrainingService; @@ -100,204 +99,4 @@ describe('TrainingService', () => { expect(service.getUpdatingSelectedStaffForMultipleTraining()).toBe(null); }); }); - - describe('addCertificateToTraining', () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - - const mockUploadFiles = [new File([''], 'certificate.pdf')]; - const mockFileId = 'mockFileId'; - const mockEtagFromS3 = 'mock-etag'; - const mockSignedUrl = 'http://localhost/mock-signed-url-for-upload'; - - const certificateEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate`; - - it('should call to backend to retreive a signed url to upload certificate', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - expect(signedUrlRequest.request.method).toBe('POST'); - }); - - it('should have request body that contains filename', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - - const expectedRequestBody = { files: [{ filename: 'certificate.pdf' }] }; - - expect(signedUrlRequest.request.body).toEqual(expectedRequestBody); - }); - - it('should upload the file with the signed url received from backend', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - signedUrlRequest.flush({ - files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId }], - }); - - const uploadToS3Request = http.expectOne(mockSignedUrl); - expect(uploadToS3Request.request.method).toBe('PUT'); - expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[0]); - }); - - it('should call to backend to confirm upload complete', async () => { - const mockKey = 'abcd/adsadsadvfdv/123dsvf'; - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - signedUrlRequest.flush({ - files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId, key: mockKey }], - }); - - const uploadToS3Request = http.expectOne(mockSignedUrl); - uploadToS3Request.flush(null, { headers: { etag: mockEtagFromS3 } }); - - const confirmUploadRequest = http.expectOne(certificateEndpoint); - const expectedconfirmUploadReqBody = { - files: [{ filename: mockUploadFiles[0].name, fileId: mockFileId, etag: mockEtagFromS3, key: mockKey }], - }; - - expect(confirmUploadRequest.request.method).toBe('PUT'); - expect(confirmUploadRequest.request.body).toEqual(expectedconfirmUploadReqBody); - }); - - describe('multiple files upload', () => { - const mockUploadFilenames = ['certificate1.pdf', 'certificate2.pdf', 'certificate3.pdf']; - const mockUploadFiles = mockUploadFilenames.map((filename) => new File([''], filename)); - const mockFileIds = ['fileId1', 'fileId2', 'fileId3']; - const mockEtags = ['etag1', 'etag2', 'etag3']; - const mockSignedUrls = mockFileIds.map((fileId) => `${mockSignedUrl}/${fileId}`); - const mockKeys = mockFileIds.map((fileId) => `${fileId}/mockKey`); - - const mockSignedUrlResponse = { - files: mockUploadFilenames.map((filename, index) => ({ - filename, - signedUrl: mockSignedUrls[index], - fileId: mockFileIds[index], - key: mockKeys[index], - })), - }; - - const expectedSignedUrlReqBody = { - files: [ - { filename: mockUploadFiles[0].name }, - { filename: mockUploadFiles[1].name }, - { filename: mockUploadFiles[2].name }, - ], - }; - const expectedConfirmUploadReqBody = { - files: [ - { filename: mockUploadFiles[0].name, fileId: mockFileIds[0], etag: mockEtags[0], key: mockKeys[0] }, - { filename: mockUploadFiles[1].name, fileId: mockFileIds[1], etag: mockEtags[1], key: mockKeys[1] }, - { filename: mockUploadFiles[2].name, fileId: mockFileIds[2], etag: mockEtags[2], key: mockKeys[2] }, - ], - }; - - it('should be able to upload multiple files at the same time', () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne({ method: 'POST', url: certificateEndpoint }); - expect(signedUrlRequest.request.body).toEqual(expectedSignedUrlReqBody); - - signedUrlRequest.flush(mockSignedUrlResponse); - - mockSignedUrls.forEach((signedUrl, index) => { - const uploadToS3Request = http.expectOne(signedUrl); - expect(uploadToS3Request.request.method).toBe('PUT'); - expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[index]); - - uploadToS3Request.flush(null, { headers: { etag: mockEtags[index] } }); - }); - - const confirmUploadRequest = http.expectOne({ method: 'PUT', url: certificateEndpoint }); - expect(confirmUploadRequest.request.body).toEqual(expectedConfirmUploadReqBody); - }); - }); - }); - - describe('downloadCertificates', () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - - const mockFiles = [{ uid: 'mockCertificateUid123', filename: 'mockCertificateName' }]; - - const certificateDownloadEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/download`; - - it('should make call to expected backend endpoint', async () => { - service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); - - const downloadRequest = http.expectOne(certificateDownloadEndpoint); - expect(downloadRequest.request.method).toBe('POST'); - }); - - it('should have request body that contains file uid', async () => { - service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); - - const downloadRequest = http.expectOne(certificateDownloadEndpoint); - const expectedRequestBody = { filesToDownload: mockFiles }; - - expect(downloadRequest.request.body).toEqual(expectedRequestBody); - }); - }); - - describe('triggerCertificateDownloads', () => { - it('should download certificates by creating and triggering anchor tag, then cleaning DOM', () => { - const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; - const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); - const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; - - const createElementSpy = spyOn(document, 'createElement').and.callThrough(); - const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); - const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); - const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); - spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); - - service.triggerCertificateDownloads(mockCertificates).subscribe(); - - const downloadReq = http.expectOne(mockCertificates[0].signedUrl); - downloadReq.flush(mockBlob); - - // Assert anchor element appended - expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(appendChildSpy).toHaveBeenCalled(); - - // Assert anchor element has correct attributes - const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; - expect(createdAnchor.href).toBe(mockBlobUrl); - expect(createdAnchor.download).toBe(mockCertificates[0].filename); - - // Assert DOM is cleaned up after download - expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); - expect(removeChildSpy).toHaveBeenCalled(); - }); - - describe('deleteCertificates', () => { - it('should call the endpoint for deleting training certificates', async () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - const mockFilesToDelete = [ - { - uid: 'uid-1', - filename: 'first_aid_v1.pdf', - uploadDate: '2024-09-23T11:02:10.000Z', - }, - ]; - - const deleteCertificatesEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/delete`; - - service.deleteCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFilesToDelete).subscribe(); - - const deleteRequest = http.expectOne(deleteCertificatesEndpoint); - const expectedRequestBody = { filesToDelete: mockFilesToDelete }; - - expect(deleteRequest.request.method).toBe('POST'); - expect(deleteRequest.request.body).toEqual(expectedRequestBody); - }); - }); - }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index 830e6a9353..c939965723 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -20,7 +20,7 @@ import { AddEditQualificationComponent } from './add-edit-qualification.componen import { QualificationCertificateService } from '@core/services/certificate.service'; import { MockQualificationCertificateService } from '@core/test-utils/MockCertificationService'; -fdescribe('AddEditQualificationComponent', () => { +describe('AddEditQualificationComponent', () => { async function setup(qualificationId = '1', qualificationInService = null, override: any = {}) { const { fixture, getByText, getByTestId, queryByText, queryByTestId, getByLabelText, getAllByText } = await render( AddEditQualificationComponent, diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts index fcbce6f949..38d63a9cb5 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts @@ -22,6 +22,8 @@ import sinon from 'sinon'; import { SelectUploadFileComponent } from '../../../shared/components/select-upload-file/select-upload-file.component'; import { AddEditTrainingComponent } from './add-edit-training.component'; +import { TrainingCertificateService } from '@core/services/certificate.service'; +import { MockTrainingCertificateService } from '@core/test-utils/MockCertificationService'; describe('AddEditTrainingComponent', () => { async function setup(trainingRecordId = '1', qsParamGetMock = sinon.fake()) { @@ -55,6 +57,10 @@ describe('AddEditTrainingComponent', () => { { provide: TrainingService, useClass: MockTrainingService }, { provide: WorkerService, useClass: MockWorkerServiceWithWorker }, { provide: TrainingCategoryService, useClass: MockTrainingCategoryService }, + { + provide: TrainingCertificateService, + useClass: MockTrainingCertificateService, + }, ], }, ); @@ -72,6 +78,7 @@ describe('AddEditTrainingComponent', () => { const alertServiceSpy = spyOn(alertService, 'addAlert'); const trainingService = injector.inject(TrainingService) as TrainingService; + const certificateService = injector.inject(TrainingCertificateService) as TrainingCertificateService; return { component, @@ -88,6 +95,7 @@ describe('AddEditTrainingComponent', () => { workerService, alertServiceSpy, trainingService, + certificateService, }; } @@ -563,8 +571,8 @@ describe('AddEditTrainingComponent', () => { describe('upload certificate of an existing training', () => { const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); - it('should call both `addCertificateToTraining` and `updateTrainingRecord` if an upload file is selected', async () => { - const { component, fixture, getByText, getByLabelText, getByTestId, updateSpy, routerSpy, trainingService } = + it('should call both `addCertificates` and `updateTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, updateSpy, routerSpy, certificateService } = await setup(); component.previousUrl = ['/goToPreviousUrl']; @@ -572,9 +580,7 @@ describe('AddEditTrainingComponent', () => { openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( - of(null), - ); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of(null)); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); userEvent.upload(getByTestId('fileInput'), mockUploadFile); @@ -595,7 +601,7 @@ describe('AddEditTrainingComponent', () => { }, ); - expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + expect(addCertificatesSpy).toHaveBeenCalledWith( component.workplace.uid, component.worker.uid, component.trainingRecordId, @@ -605,28 +611,28 @@ describe('AddEditTrainingComponent', () => { expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); - it('should not call addCertificateToTraining if no file was selected', async () => { - const { component, fixture, getByText, getByLabelText, trainingService } = await setup(); + it('should not call addCertificates if no file was selected', async () => { + const { component, fixture, getByText, getByLabelText, certificateService } = await setup(); component.previousUrl = ['/goToPreviousUrl']; const openNotesButton = getByText('Open notes'); openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates'); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); fireEvent.click(getByText('Save and return')); - expect(addCertificateToTrainingSpy).not.toHaveBeenCalled(); + expect(addCertificatesSpy).not.toHaveBeenCalled(); }); }); describe('add a new training record and upload certificate together', async () => { const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); - it('should call both `addCertificateToTraining` and `createTrainingRecord` if an upload file is selected', async () => { - const { component, fixture, getByText, getByLabelText, getByTestId, createSpy, routerSpy, trainingService } = + it('should call both `addCertificates` and `createTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, createSpy, routerSpy, certificateService } = await setup(null); component.previousUrl = ['/goToPreviousUrl']; @@ -637,9 +643,7 @@ describe('AddEditTrainingComponent', () => { fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( - of(null), - ); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of(null)); userEvent.type(getByLabelText('Training name'), 'Understanding Autism'); userEvent.click(getByLabelText('Yes')); @@ -657,7 +661,7 @@ describe('AddEditTrainingComponent', () => { notes: null, }); - expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + expect(addCertificatesSpy).toHaveBeenCalledWith( component.workplace.uid, component.worker.uid, trainingRecord.uid, @@ -667,8 +671,8 @@ describe('AddEditTrainingComponent', () => { expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); - it('should not call `addCertificateToTraining` when no upload file was selected', async () => { - const { component, fixture, getByText, getByLabelText, createSpy, routerSpy, trainingService } = await setup( + it('should not call `addCertificates` when no upload file was selected', async () => { + const { component, fixture, getByText, getByLabelText, createSpy, routerSpy, certificateService } = await setup( null, ); @@ -681,14 +685,14 @@ describe('AddEditTrainingComponent', () => { openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates'); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); fireEvent.click(getByText('Save record')); expect(createSpy).toHaveBeenCalled; - expect(addCertificateToTrainingSpy).not.toHaveBeenCalled; + expect(addCertificatesSpy).not.toHaveBeenCalled; expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); @@ -1052,9 +1056,9 @@ describe('AddEditTrainingComponent', () => { }); it('should make call to downloadCertificates with required uids and file uid in array when Download button clicked', async () => { - const { component, fixture, getByTestId, trainingService } = await setup(); + const { component, fixture, getByTestId, certificateService } = await setup(); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( of({ files: ['abc123'] }), ); component.trainingCertificates = [mockTrainingCertificate]; @@ -1073,9 +1077,9 @@ describe('AddEditTrainingComponent', () => { }); it('should make call to downloadCertificates with all certificate file uids in array when Download all button clicked', async () => { - const { component, fixture, getByTestId, trainingService } = await setup(); + const { component, fixture, getByTestId, certificateService } = await setup(); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( of({ files: ['abc123'] }), ); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; @@ -1097,9 +1101,9 @@ describe('AddEditTrainingComponent', () => { }); it('should display error message when Download fails', async () => { - const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + const { component, fixture, getByText, getByTestId, certificateService } = await setup(); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; fixture.detectChanges(); @@ -1116,9 +1120,9 @@ describe('AddEditTrainingComponent', () => { }); it('should display error message when Download all fails', async () => { - const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + const { component, fixture, getByText, getByTestId, certificateService } = await setup(); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('some download error')); + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('some download error')); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; fixture.detectChanges(); @@ -1251,7 +1255,7 @@ describe('AddEditTrainingComponent', () => { }); it('should call the training service when save and return is clicked', async () => { - const { component, fixture, getByTestId, getByText, trainingService } = await setup(); + const { component, fixture, getByTestId, getByText, certificateService } = await setup(); component.trainingCertificates = [ { @@ -1271,7 +1275,7 @@ describe('AddEditTrainingComponent', () => { const certificateRow = getByTestId('certificate-row-0'); const removeButtonForRow = within(certificateRow).getByText('Remove'); - const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + const trainingServiceSpy = spyOn(certificateService, 'deleteCertificates').and.callThrough(); fireEvent.click(removeButtonForRow); fireEvent.click(getByText('Save and return')); @@ -1286,13 +1290,13 @@ describe('AddEditTrainingComponent', () => { }); it('should not call the training service when save and return is clicked and there are no files to remove ', async () => { - const { component, fixture, getByText, trainingService } = await setup(); + const { component, fixture, getByText, certificateService } = await setup(); component.trainingCertificates = []; fixture.detectChanges(); - const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + const trainingServiceSpy = spyOn(certificateService, 'deleteCertificates').and.callThrough(); fireEvent.click(getByText('Save and return')); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts index df99062d84..8f597c53de 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts @@ -6,6 +6,7 @@ import { DATE_PARSE_FORMAT } from '@core/constants/constants'; import { CertificateDownload, TrainingCertificate } from '@core/model/training.model'; import { AlertService } from '@core/services/alert.service'; import { BackLinkService } from '@core/services/backLink.service'; +import { TrainingCertificateService } from '@core/services/certificate.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; @@ -36,6 +37,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement protected errorSummaryService: ErrorSummaryService, protected trainingService: TrainingService, protected trainingCategoryService: TrainingCategoryService, + protected certificateService: TrainingCertificateService, protected workerService: WorkerService, protected alertService: AlertService, protected http: HttpClient, @@ -210,7 +212,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement private uploadNewCertificate(trainingRecordResponse: any) { const trainingRecordId = this.trainingRecordId ?? trainingRecordResponse.uid; - return this.trainingService.addCertificateToTraining( + return this.certificateService.addCertificates( this.workplace.uid, this.worker.uid, trainingRecordId, @@ -226,7 +228,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement return this.formatForCertificateDownload(certificate); }); this.subscriptions.add( - this.trainingService + this.certificateService .downloadCertificates(this.workplace.uid, this.worker.uid, this.trainingRecordId, filesToDownload) .subscribe( () => { @@ -245,7 +247,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement private deleteTrainingCertificate(files: TrainingCertificate[]) { this.subscriptions.add( - this.trainingService + this.certificateService .deleteCertificates(this.establishmentUid, this.workerId, this.trainingRecordId, files) .subscribe(() => {}), ); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts index 549ba9f24d..57f04969cf 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts @@ -27,6 +27,8 @@ import userEvent from '@testing-library/user-event'; import { TrainingRecord, TrainingRecords } from '@core/model/training.model'; import { TrainingService } from '@core/services/training.service'; import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; +import { TrainingCertificateService } from '@core/services/certificate.service'; +import { MockTrainingCertificateService } from '@core/test-utils/MockCertificationService'; describe('NewTrainingAndQualificationsRecordComponent', () => { const workplace = establishmentBuilder() as Establishment; @@ -315,6 +317,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { { provide: EstablishmentService, useClass: MockEstablishmentService }, { provide: BreadcrumbService, useClass: MockBreadcrumbService }, { provide: PermissionsService, useClass: MockPermissionsService }, + { provide: TrainingCertificateService, useClass: MockTrainingCertificateService }, ], }, ); @@ -332,6 +335,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const workerService = injector.inject(WorkerService) as WorkerService; const trainingService = injector.inject(TrainingService) as TrainingService; + const trainingCertificateService = injector.inject(TrainingCertificateService) as TrainingCertificateService; const workerSpy = spyOn(workerService, 'setReturnTo'); workerSpy.and.callThrough(); @@ -361,6 +365,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { alertSpy, pdfTrainingAndQualsService, parentSubsidiaryViewService, + trainingCertificateService, }; } @@ -821,13 +826,15 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { ]; it('should download a certificate file when download link of a certificate row is clicked', async () => { - const { getByTestId, component, trainingService } = await setup(false, true, mockTrainings); + const { getByTestId, component, trainingCertificateService } = await setup(false, true, mockTrainings); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); const downloadLink = within(trainingRecordRow).getByText('Download'); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue(of(null)); + const downloadCertificatesSpy = spyOn(trainingCertificateService, 'downloadCertificates').and.returnValue( + of(null), + ); userEvent.click(downloadLink); expect(downloadCertificatesSpy).toHaveBeenCalledWith( @@ -839,7 +846,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should call triggerCertificateDownloads with file returned from downloadCertificates', async () => { - const { getByTestId, trainingService } = await setup(false, true, mockTrainings); + const { getByTestId, trainingCertificateService } = await setup(false, true, mockTrainings); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); @@ -848,10 +855,11 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { { filename: 'test.pdf', signedUrl: 'signedUrl.com/1872ec19-510d-41de-995d-6abfd3ae888a' }, ]; - const triggerCertificateDownloadsSpy = spyOn(trainingService, 'triggerCertificateDownloads').and.returnValue( - of(null), - ); - spyOn(trainingService, 'getCertificateDownloadUrls').and.returnValue( + const triggerCertificateDownloadsSpy = spyOn( + trainingCertificateService, + 'triggerCertificateDownloads', + ).and.returnValue(of(null)); + spyOn(trainingCertificateService, 'getCertificateDownloadUrls').and.returnValue( of({ files: filesReturnedFromDownloadCertificates }), ); @@ -861,14 +869,14 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should display an error message on the training category when certificate download fails', async () => { - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, mockTrainings); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(false, true, mockTrainings); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); const downloadLink = within(trainingRecordRow).getByText('Download'); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + spyOn(trainingCertificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); userEvent.click(downloadLink); fixture.detectChanges(); @@ -881,8 +889,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const mockUploadFile = new File(['some file content'], 'certificate.pdf'); it('should upload a file when a file is selected from Upload file button', async () => { - const { component, getByTestId, trainingService } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + const { component, getByTestId, trainingCertificateService } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates').and.returnValue(of(null)); const trainingRecordRow = getByTestId('someHealthuid'); @@ -901,8 +909,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { it('should show an error message when a non pdf file is selected', async () => { const invalidFile = new File(['some file content'], 'certificate.csv'); - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates'); const trainingRecordRow = getByTestId('someHealthuid'); @@ -922,8 +930,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { value: 600 * 1024, // 600 KB }); - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(false, true, []); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates'); const trainingRecordRow = getByTestId('someHealthuid'); @@ -938,7 +946,11 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should refresh the training record and display an alert of "Certificate uploaded" on successful upload', async () => { - const { fixture, alertSpy, getByTestId, workerService, trainingService } = await setup(false, true, []); + const { fixture, alertSpy, getByTestId, workerService, trainingCertificateService } = await setup( + false, + true, + [], + ); const mockUpdatedData = { training: mockTrainingData, qualifications: { count: 0, groups: [], lastUpdated: null }, @@ -946,7 +958,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const workerSpy = spyOn(workerService, 'getAllTrainingAndQualificationRecords').and.returnValue( of(mockUpdatedData), ); - spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + spyOn(trainingCertificateService, 'addCertificates').and.returnValue(of(null)); const trainingRecordRow = getByTestId('someHealthuid'); const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); @@ -963,8 +975,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should display an error message on the training category when certificate upload fails', async () => { - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - spyOn(trainingService, 'addCertificateToTraining').and.returnValue(throwError('failed to upload')); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(false, true, []); + spyOn(trainingCertificateService, 'addCertificates').and.returnValue(throwError('failed to upload')); const trainingRecordRow = getByTestId('someHealthuid'); const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts index 71bb6cf0d4..29a72f1857 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts @@ -1,6 +1,7 @@ import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { JourneyType } from '@core/breadcrumb/breadcrumb.model'; +import { TrainingCertificateService, QualificationCertificateService } from '@core/services/certificate.service'; import { Establishment, mandatoryTraining } from '@core/model/establishment.model'; import { QualificationsByGroup } from '@core/model/qualification.model'; import { CertificateUpload, TrainingRecord, TrainingRecordCategory } from '@core/model/training.model'; @@ -15,10 +16,9 @@ import { TrainingService } from '@core/services/training.service'; import { TrainingStatusService } from '@core/services/trainingStatus.service'; import { WorkerService } from '@core/services/worker.service'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; import { Subscription } from 'rxjs'; -import { CustomValidators } from '../../../shared/validators/custom-form-validators'; - @Component({ selector: 'app-new-training-and-qualifications-record', templateUrl: './new-training-and-qualifications-record.component.html', @@ -59,6 +59,8 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe private router: Router, private trainingStatusService: TrainingStatusService, private trainingService: TrainingService, + private trainingCertificateService: TrainingCertificateService, + private qualificationCertificateService: QualificationCertificateService, private workerService: WorkerService, private alertService: AlertService, public viewContainerRef: ViewContainerRef, @@ -336,7 +338,7 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe } public downloadTrainingCertificate(trainingRecord: TrainingRecord): void { - this.trainingService + this.trainingCertificateService .downloadCertificates( this.workplace.uid, this.worker.uid, @@ -366,8 +368,8 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe return; } - this.trainingService - .addCertificateToTraining(this.workplace.uid, this.worker.uid, trainingRecord.uid, files) + this.trainingCertificateService + .addCertificates(this.workplace.uid, this.worker.uid, trainingRecord.uid, files) .subscribe( () => { this.certificateErrors = {}; From 742ac78839f90e0ac7fe3d3104b36b1085bf56c3 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 11:17:29 +0100 Subject: [PATCH 012/100] remove commented out / duplicated code in add-edit-training component --- .../add-edit-training.component.ts | 10 ------ .../add-edit-training.component.html | 35 ------------------- 2 files changed, 45 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts index 8f597c53de..d6e73a7483 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts @@ -181,16 +181,6 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement this.certificateErrors = null; } - public getUploadComponentAriaDescribedBy(): string { - if (this.certificateErrors) { - return 'uploadCertificate-errors uploadCertificate-aria-text'; - } else if (this.filesToUpload?.length > 0) { - return 'uploadCertificate-aria-text'; - } else { - return 'uploadCertificate-hint uploadCertificate-aria-text'; - } - } - public onSelectFiles(newFiles: File[]): void { this.resetUploadFilesError(); const errors = CustomValidators.validateUploadCertificates(newFiles); diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html index 662875ce3d..fd12d1931e 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html @@ -183,41 +183,6 @@

- Date: Fri, 11 Oct 2024 11:39:08 +0100 Subject: [PATCH 013/100] minor change in typings --- frontend/src/app/core/model/qualification.model.ts | 4 ++-- frontend/src/app/core/model/training.model.ts | 8 +++----- .../src/app/core/model/trainingAndQualifications.model.ts | 6 +++++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/core/model/qualification.model.ts b/frontend/src/app/core/model/qualification.model.ts index fe38dc8ffe..f3d02722e9 100644 --- a/frontend/src/app/core/model/qualification.model.ts +++ b/frontend/src/app/core/model/qualification.model.ts @@ -1,4 +1,4 @@ -import { TrainingCertificate } from './training.model'; +import { Certificate } from './trainingAndQualifications.model'; export enum QualificationType { NVQ = 'NVQ', @@ -86,4 +86,4 @@ export interface BasicQualificationRecord { year: number; } -export interface QualificationCertificate extends TrainingCertificate {} +export interface QualificationCertificate extends Certificate {} diff --git a/frontend/src/app/core/model/training.model.ts b/frontend/src/app/core/model/training.model.ts index 855039d5ef..b73185354b 100644 --- a/frontend/src/app/core/model/training.model.ts +++ b/frontend/src/app/core/model/training.model.ts @@ -1,3 +1,5 @@ +import { Certificate } from './trainingAndQualifications.model'; + export interface TrainingCategory { id: number; seq: number; @@ -46,11 +48,7 @@ export interface CertificateUpload { trainingRecord: TrainingRecord; } -export interface TrainingCertificate { - uid: string; - filename: string; - uploadDate: string; -} +export interface TrainingCertificate extends Certificate {} export interface TrainingRecord { accredited?: boolean; diff --git a/frontend/src/app/core/model/trainingAndQualifications.model.ts b/frontend/src/app/core/model/trainingAndQualifications.model.ts index 2e1e4391b9..247b28d360 100644 --- a/frontend/src/app/core/model/trainingAndQualifications.model.ts +++ b/frontend/src/app/core/model/trainingAndQualifications.model.ts @@ -15,4 +15,8 @@ export interface TrainingCounts { staffMissingMandatoryTraining?: number; } -export type Certificate = TrainingCertificate | QualificationCertificate; +export interface Certificate { + uid: string; + filename: string; + uploadDate: string; +} From dbfa5229ea521b1255a92c0cd103f165a6f4b07f Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 12:14:36 +0100 Subject: [PATCH 014/100] amend method name --- .../src/app/core/services/certificate.service.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/core/services/certificate.service.ts b/frontend/src/app/core/services/certificate.service.ts index 59201f2455..106279e722 100644 --- a/frontend/src/app/core/services/certificate.service.ts +++ b/frontend/src/app/core/services/certificate.service.ts @@ -25,7 +25,7 @@ export class BaseCertificateService { } } - protected getCertificateEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { + protected certificateEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { throw new Error('Not implemented for base class'); } @@ -37,7 +37,7 @@ export class BaseCertificateService { ): Observable { const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; - const endpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + const endpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); return this.http.post(endpoint, requestBody).pipe( mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), @@ -89,7 +89,7 @@ export class BaseCertificateService { recordUid: string, confirmUploadRequestBody: ConfirmUploadRequest, ) { - const endpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + const endpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); return this.http.put(endpoint, confirmUploadRequestBody); } @@ -110,7 +110,7 @@ export class BaseCertificateService { recordUid: string, filesToDownload: CertificateDownload[], ) { - const certificateEndpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + const certificateEndpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); return this.http.post(`${certificateEndpoint}/download`, { filesToDownload }); } @@ -156,21 +156,21 @@ export class BaseCertificateService { recordUid: string, filesToDelete: Certificate[], ): Observable { - const certificateEndpoint = this.getCertificateEndpoint(workplaceUid, workerUid, recordUid); + const certificateEndpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); return this.http.post(`${certificateEndpoint}/delete`, { filesToDelete }); } } @Injectable() export class TrainingCertificateService extends BaseCertificateService { - protected getCertificateEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { + protected certificateEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`; } } @Injectable() export class QualificationCertificateService extends BaseCertificateService { - protected getCertificateEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { + protected certificateEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/qualification/${qualificationUid}/certificate`; } } From ee35b6e7e895995c7ccf6e5a8d2e5c7358133981 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 12:15:27 +0100 Subject: [PATCH 015/100] move tests about upload button to the new shared component (select-upload-certificate) --- .../add-edit-qualification.component.html | 2 +- .../add-edit-training.component.spec.ts | 55 ---------- ...elect-upload-certificate.component.spec.ts | 101 +++++++++++++++--- 3 files changed, 88 insertions(+), 70 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 293caf3f7c..e6d680e880 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -75,7 +75,7 @@

Type: {{ qualificationType }}

- + { }); }); - describe('Upload file button', () => { - it('should render a file input element', async () => { - const { getByTestId } = await setup(null); - - const uploadSection = getByTestId('uploadCertificate'); - expect(uploadSection).toBeTruthy(); - - const fileInput = within(uploadSection).getByTestId('fileInput'); - expect(fileInput).toBeTruthy(); - }); - - it('should render "No file chosen" beside the file input', async () => { - const { getByTestId } = await setup(null); - - const uploadSection = getByTestId('uploadCertificate'); - - const text = within(uploadSection).getByText('No file chosen'); - expect(text).toBeTruthy(); - }); - - it('should not render "No file chosen" when a file is chosen', async () => { - const { fixture, getByTestId } = await setup(null); - - const uploadSection = getByTestId('uploadCertificate'); - const fileInput = getByTestId('fileInput'); - - userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); - - fixture.detectChanges(); - - const text = within(uploadSection).queryByText('No file chosen'); - expect(text).toBeFalsy(); - }); - - it('should provide aria description to screen reader users', async () => { - const { fixture, getByTestId } = await setup(null); - fixture.autoDetectChanges(); - - const uploadSection = getByTestId('uploadCertificate'); - const fileInput = getByTestId('fileInput'); - - let uploadButton = within(uploadSection).getByRole('button', { - description: /The certificate must be a PDF file that's no larger than 500KB/, - }); - expect(uploadButton).toBeTruthy(); - - userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); - - uploadButton = within(uploadSection).getByRole('button', { - description: '1 file chosen', - }); - expect(uploadButton).toBeTruthy(); - }); - }); - describe('submitting form', () => { it('should call the updateTrainingRecord function if editing existing training, and navigate away from page', async () => { const { component, fixture, getByText, getByLabelText, updateSpy, routerSpy, alertServiceSpy } = await setup(); diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts index cf47191466..917cba121c 100644 --- a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts @@ -1,23 +1,96 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; import { SelectUploadCertificateComponent } from './select-upload-certificate.component'; +import userEvent from '@testing-library/user-event'; describe('SelectUploadCertificateComponent', () => { - let component: SelectUploadCertificateComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ SelectUploadCertificateComponent ] - }) - .compileComponents(); - - fixture = TestBed.createComponent(SelectUploadCertificateComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + let filesToUpload = []; + beforeEach(() => { + filesToUpload = []; }); - it('should create', () => { + const setup = async (inputOverride: any = {}) => { + const mockOnSelectFiles = (files: File[]) => { + filesToUpload.push(...files); + }; + + const { fixture, getByText, getByTestId, getByRole } = await render(SelectUploadCertificateComponent, { + imports: [SharedModule], + componentProperties: { + onSelectFiles: mockOnSelectFiles, + filesToUpload, + ...inputOverride, + }, + }); + + const component = fixture.componentInstance; + + return { + component, + fixture, + getByText, + getByRole, + getByTestId, + }; + }; + + it('should create', async () => { + const { component } = await setup(); expect(component).toBeTruthy(); }); + + it('should render a subheading', async () => { + const { getByRole } = await setup(); + expect(getByRole('heading', { name: 'Certificates' })).toBeTruthy; + }); + + describe('Upload file button', () => { + it('should render a file input element', async () => { + const { getByTestId } = await setup(); + + const fileInput = getByTestId('fileInput'); + expect(fileInput).toBeTruthy(); + }); + + it('should render "No file chosen" beside the file input', async () => { + const { getByText } = await setup(); + const text = getByText('No file chosen'); + expect(text).toBeTruthy(); + }); + + it('should not render "No file chosen" when a file is chosen', async () => { + const { fixture, getByTestId } = await setup(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + fixture.detectChanges(); + + const text = within(uploadSection).queryByText('No file chosen'); + expect(text).toBeFalsy(); + }); + + it('should provide aria description to screen reader users', async () => { + const { fixture, getByTestId } = await setup(); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + let uploadButton = within(uploadSection).getByRole('button', { + description: /The certificate must be a PDF file that's no larger than 500KB/, + }); + expect(uploadButton).toBeTruthy(); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + uploadButton = within(uploadSection).getByRole('button', { + description: '1 file chosen', + }); + expect(uploadButton).toBeTruthy(); + }); + }); }); From cc50c577cf450e6232981d50440035450022d1f5 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 12:44:16 +0100 Subject: [PATCH 016/100] amend one unit test --- .../add-edit-qualification.component.spec.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index c939965723..14784b2b74 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -351,28 +351,15 @@ describe('AddEditQualificationComponent', () => { ); }); - it('should call certificateService with the selected files on form submit (for existing qualification)', async () => { - const mockGetQualificationResponse = { - uid: 'mockQualificationUid', - qualification: mockQualification, - created: '', - updated: '', - updatedBy: '', - }; - - const { component, fixture, getByTestId, getByText, certificateService, workerService } = await setup( - 'mockQualificationUid', - ); + it('should call both `addCertificates` and `updateQualification` if an upload file is selected (for existing qualification)', async () => { + const { component, getByTestId, getByText, certificateService, getByLabelText, updateQualificationSpy } = + await setupWithExistingQualification(); const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of('null')); - spyOn(workerService, 'getQualification').and.returnValue(of(mockGetQualificationResponse)); - spyOn(workerService, 'updateQualification').and.returnValue(of(null)); - - component.ngOnInit(); - fixture.autoDetectChanges(); userEvent.upload(getByTestId('fileInput'), mockUploadFile1); - userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + userEvent.clear(getByLabelText('Year achieved')); + userEvent.type(getByLabelText('Year achieved'), '2023'); userEvent.click(getByText('Save and return')); @@ -380,7 +367,14 @@ describe('AddEditQualificationComponent', () => { component.workplace.uid, component.worker.uid, component.qualificationId, - [mockUploadFile1, mockUploadFile2], + [mockUploadFile1], + ); + + expect(updateQualificationSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + jasmine.objectContaining({ year: 2023 }), ); }); }); From f078f2438b210b166bc81ea20e261bb79d0a6385 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Fri, 11 Oct 2024 13:18:49 +0100 Subject: [PATCH 017/100] add logic to prevent submit button being triggered more than once --- .../add-edit-qualification.component.html | 2 +- .../add-edit-qualification.component.spec.ts | 10 ++++++++++ .../add-edit-qualification.component.ts | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index e6d680e880..03e679509a 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -153,7 +153,7 @@

Type: {{ qualificationType }}

- + { jasmine.objectContaining({ year: 2023 }), ); }); + + it('should disable the submit button to prevent it being triggered more than once', async () => { + const { fixture, getByText } = await setup(null); + + const submitButton = getByText('Save record') as HTMLButtonElement; + userEvent.click(submitButton); + fixture.detectChanges(); + + expect(submitButton.disabled).toBe(true); + }); }); describe('saved certificates', () => { diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts index 953d47c94f..5e096706e5 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts @@ -58,6 +58,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { private _filesToUpload: File[]; public filesToRemove: QualificationCertificate[] = []; public certificateErrors: string[] | null; + public submitButtonDisabled: boolean = false; constructor( private trainingService: TrainingService, @@ -205,6 +206,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { return; } + this.submitButtonDisabled = true; this.qualificationService.clearSelectedQualification(); const { year, notes } = this.form.value; @@ -299,6 +301,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { } private onError(error): void { + this.submitButtonDisabled = false; console.log(error); } From 1ffc9d916395b6e97246a8c5975c95c904ec258b Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Mon, 14 Oct 2024 11:46:29 +0100 Subject: [PATCH 018/100] clear error message on remove button clicked, css adjustment --- .../add-edit-qualification.component.html | 181 +++++++----------- .../add-edit-qualification.component.spec.ts | 19 +- .../add-edit-qualification.component.ts | 1 + .../add-edit-training.component.spec.ts | 17 ++ .../add-edit-training.component.ts | 1 + .../select-upload-file.component.ts | 1 + 6 files changed, 106 insertions(+), 114 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 03e679509a..d4c96bca90 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -13,17 +13,11 @@

- + - Delete this qualification record + Delete this qualification record
@@ -39,130 +33,91 @@

Type: {{ qualificationType }}

{{ qualificationTitle }} - Change + Change
-
- -
-
- - - Error: {{ getFirstErrorMessage('year') }} - - -
-
+ +
+
+ + + Error: {{ getFirstErrorMessage('year') }} + + +
+
-
- - -
+
+ + +
-
-
-
- - - Error: {{ getFirstErrorMessage('notes') }} - - -
- - + {{ !notesOpen ? 'Open notes' : 'Close notes' }} + +
+ + - - You have {{ remainingCharacterCount | absoluteNumber | number }} - {{ - remainingCharacterCount - | absoluteNumber - | i18nPlural - : { - '=1': 'character', - other: 'characters' - } - }} + }" aria-live="polite"> + + You have {{ remainingCharacterCount | absoluteNumber | number }} + {{ + remainingCharacterCount + | absoluteNumber + | i18nPlural + : { + '=1': 'character', + other: 'characters' + } + }} - {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} - - -
-
+ {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} + +
- +
+
- + \ No newline at end of file diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index 45f8fcb852..5f16986edf 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -379,7 +379,7 @@ describe('AddEditQualificationComponent', () => { }); it('should disable the submit button to prevent it being triggered more than once', async () => { - const { fixture, getByText } = await setup(null); + const { fixture, getByText } = await setup(null, mockQualification); const submitButton = getByText('Save record') as HTMLButtonElement; userEvent.click(submitButton); @@ -833,6 +833,23 @@ describe('AddEditQualificationComponent', () => { }); expect(uploadButton).toBeTruthy(); }); + + it('should clear any error message when remove button of an upload file is clicked', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const mockUploadFileValid = new File(['some file content'], 'cerfificate.pdf', { type: 'application/pdf' }); + const mockUploadFileInvalid = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileValid]); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileInvalid]); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + const removeButton = within(getByText('cerfificate.pdf').parentElement).getByText('Remove'); + userEvent.click(removeButton); + + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts index 5e096706e5..6c76a3f9e0 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts @@ -337,6 +337,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { public removeFileToUpload(fileIndexToRemove: number): void { const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); this.filesToUpload = filesToKeep; + this.certificateErrors = []; } public removeSavedFile(fileIndexToRemove: number): void { diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts index ecffeb16c0..d64e75460b 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts @@ -932,6 +932,23 @@ describe('AddEditTrainingComponent', () => { }); expect(uploadButton).toBeTruthy(); }); + + it('should clear any error message when remove button of an upload file is clicked', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const mockUploadFileValid = new File(['some file content'], 'cerfificate.pdf', { type: 'application/pdf' }); + const mockUploadFileInvalid = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileValid]); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileInvalid]); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + const removeButton = within(getByText('cerfificate.pdf').parentElement).getByText('Remove'); + userEvent.click(removeButton); + + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts index d6e73a7483..15495e699c 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts @@ -197,6 +197,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement public removeFileToUpload(fileIndexToRemove: number): void { const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); this.filesToUpload = filesToKeep; + this.certificateErrors = []; } private uploadNewCertificate(trainingRecordResponse: any) { diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts index 0f347b7489..b38045bc3c 100644 --- a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts @@ -25,6 +25,7 @@ export class SelectUploadFileComponent implements OnInit { const selectedFiles = Array.from(event.target.files); if (selectedFiles?.length) { this.selectFiles.emit(selectedFiles); + event.target.value = ''; } } } From 5057a9d260d30a58fb6e43ba46b9e4b25571f805 Mon Sep 17 00:00:00 2001 From: Joe Fong Date: Mon, 14 Oct 2024 13:34:44 +0100 Subject: [PATCH 019/100] adjust css styling --- .../add-edit-qualification.component.html | 39 +++++++++++++------ .../add-edit-training.component.html | 4 +- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index d4c96bca90..d0738f6f6b 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -71,22 +71,39 @@

Type: {{ qualificationType }}

Error: {{ getFirstErrorMessage('notes') }} - -
- - + id="notes" + name="notes" + rows="5" + [value]="notesValue" + (input)="handleOnInput($event)" + > + You have {{ remainingCharacterCount | absoluteNumber | number }} {{ diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html index fd12d1931e..20a3af54bf 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html @@ -205,7 +205,7 @@

-
+