diff --git a/.github/workflows/development_hgn-rest-dev.yml b/.github/workflows/development_hgn-rest-dev.yml index 7d822c869..05030cf70 100644 --- a/.github/workflows/development_hgn-rest-dev.yml +++ b/.github/workflows/development_hgn-rest-dev.yml @@ -1,20 +1,6 @@ # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy # More GitHub Actions for Azure: https://github.com/Azure/actions -# CONFIGURATION -# For help, go to https://github.com/Azure/Actions -# -# 1. Set up the following secrets in your repository: -# AZURE_WEBAPP_PUBLISH_PROFILE -# -# 2. Change these variables for your configuration: - - - - -# For more information on GitHub Actions for Azure, refer to https://github.com/Azure/Actions -# For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples - name: Build and deploy Node.js app to Azure Web App - hgn-rest-dev on: @@ -23,33 +9,53 @@ on: - development workflow_dispatch: -env: - AZURE_WEBAPP_NAME: hgn-rest-dev # set this to your application's name - AZURE_WEBAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root - NODE_VERSION: '14.x' # set this to the node version to use - jobs: - build-and-deploy: - name: Build and Deploy + build: runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: npm install, build, and test + run: | + npm install + npm run build --if-present + + - name: Zip artifact for deployment + run: zip release.zip ./* -r + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: node-app + path: release.zip + + deploy: + runs-on: ubuntu-latest + needs: build environment: name: 'Production' url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + steps: - - uses: actions/checkout@master - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v1 - with: - node-version: ${{ env.NODE_VERSION }} - - name: npm install, build, and test - run: | - # Build and test the project, then - # deploy to Azure Web App. - npm install - npm run build --if-present - - name: 'Deploy to Azure WebApp' - uses: azure/webapps-deploy@v2 - with: - app-name: ${{ env.AZURE_WEBAPP_NAME }} - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D3183F132BC14D79A19DC24953125EA4 }} - package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: node-app + + - name: Unzip artifact for deployment + run: unzip release.zip + + - name: 'Deploy to Azure Web App' + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'hgn-rest-dev' + slot-name: 'Production' + package: . + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_37BDFCF15560478F9069CEF1EA105BF0 }} diff --git a/.github/workflows/main_hgn-rest-beta.yml b/.github/workflows/main_hgn-rest-beta.yml index 5db4b0e97..24d316a97 100644 --- a/.github/workflows/main_hgn-rest-beta.yml +++ b/.github/workflows/main_hgn-rest-beta.yml @@ -1,20 +1,6 @@ -# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy # More GitHub Actions for Azure: https://github.com/Azure/actions -# CONFIGURATION -# For help, go to https://github.com/Azure/Actions -# -# 1. Set up the following secrets in your repository: -# AZURE_WEBAPP_PUBLISH_PROFILE -# -# 2. Change these variables for your configuration: - - - - -# For more information on GitHub Actions for Azure, refer to https://github.com/Azure/Actions -# For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples - name: Build and deploy Node.js app to Azure Web App - hgn-rest-beta on: @@ -23,35 +9,53 @@ on: - main workflow_dispatch: -env: - AZURE_WEBAPP_NAME: hgn-rest-beta # set this to your application's name - AZURE_WEBAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root - NODE_VERSION: '14.x' # set this to the node version to use - jobs: - build-and-deploy: - name: Build and Deploy + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: npm install, build, and test + run: | + npm install + npm run build --if-present + + - name: Zip artifact for deployment + run: zip release.zip ./* -r + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: node-app + path: release.zip + + deploy: runs-on: ubuntu-latest + needs: build environment: name: 'Production' url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + steps: - - uses: actions/checkout@master - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v1 - with: - node-version: ${{ env.NODE_VERSION }} - - name: npm install, build, and test - run: | - # Build and test the project, then - # deploy to Azure Web App. - npm install - npm run build --if-present - - name: 'Deploy to Azure WebApp' - uses: azure/webapps-deploy@v2 - with: - app-name: ${{ env.AZURE_WEBAPP_NAME }} - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_47800FE52B59410A903D5C41C2F9C10F }} - package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} -# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy -# More GitHub Actions for Azure: https://github.com/Azure/actions + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: node-app + + - name: Unzip artifact for deployment + run: unzip release.zip + + - name: 'Deploy to Azure Web App' + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'hgn-rest-beta' + slot-name: 'Production' + package: . + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_DB4151C3B2C645B88AD84509DA4DD53D }} diff --git a/.github/workflows/main_hgn-rest.yml b/.github/workflows/main_hgn-rest.yml new file mode 100644 index 000000000..8b6a4298f --- /dev/null +++ b/.github/workflows/main_hgn-rest.yml @@ -0,0 +1,61 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy Node.js app to Azure Web App - hgn-rest + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js version + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: npm install, build, and test + run: | + npm install + npm run build --if-present + + - name: Zip artifact for deployment + run: zip release.zip ./* -r + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: node-app + path: release.zip + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: 'Production' + url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: node-app + + - name: Unzip artifact for deployment + run: unzip release.zip + + - name: 'Deploy to Azure Web App' + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'hgn-rest' + slot-name: 'Production' + package: . + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D375D726FC884C3C919531718B394AF9 }} diff --git a/package-lock.json b/package-lock.json index 3c4e3e99e..9e07004c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -619,9 +619,9 @@ }, "dependencies": { "@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true } } @@ -691,18 +691,18 @@ } }, "@babel/plugin-syntax-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz", - "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.24.8" }, "dependencies": { "@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true } } @@ -1477,9 +1477,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1593,9 +1593,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1710,9 +1710,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1825,9 +1825,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1911,9 +1911,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2023,9 +2023,9 @@ "dev": true }, "@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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2039,9 +2039,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2131,9 +2131,9 @@ "dev": true }, "@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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2175,9 +2175,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2298,9 +2298,9 @@ "dev": true }, "@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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -2314,9 +2314,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -2857,7 +2857,7 @@ "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, "@types/methods": { @@ -3081,7 +3081,7 @@ "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { "version": "3.1.6", @@ -4739,7 +4739,7 @@ "bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" }, "bignumber.js": { "version": "9.0.2", @@ -4797,6 +4797,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4869,7 +4874,7 @@ "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" }, "buffer-from": { "version": "1.1.2", @@ -4923,6 +4928,33 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4945,9 +4977,9 @@ "dev": true }, "cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", "dev": true }, "clean-stack": { @@ -5036,7 +5068,7 @@ "clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" }, "clone-deep": { "version": "4.0.1", @@ -5076,7 +5108,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "colorette": { "version": "2.0.19", @@ -5100,7 +5132,7 @@ "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, "component-emitter": { "version": "1.3.1", @@ -5110,7 +5142,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "confusing-browser-globals": { "version": "1.0.11", @@ -5154,7 +5186,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, "cookiejar": { "version": "2.1.4", @@ -5226,9 +5258,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -5321,6 +5353,23 @@ } } }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5616,7 +5665,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "electron-to-chromium": { "version": "1.4.81", @@ -5638,7 +5687,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "end-of-stream": { "version": "1.4.4", @@ -5783,12 +5832,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "eslint": { "version": "8.47.0", @@ -6718,7 +6767,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "event-target-shim": { "version": "5.0.1", @@ -6881,7 +6930,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "safe-buffer": { "version": "5.2.1", @@ -6926,7 +6975,7 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, "fast-safe-stringify": { @@ -7000,7 +7049,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, @@ -7137,7 +7186,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fs-constants": { "version": "1.0.0", @@ -7153,7 +7202,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.3", @@ -7460,7 +7509,7 @@ "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==" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-property-descriptors": { "version": "1.0.0", @@ -7609,9 +7658,9 @@ } }, "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "requires": { "pkg-dir": "^4.2.0", @@ -7666,7 +7715,7 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, "indent-string": { @@ -7678,7 +7727,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -7872,7 +7921,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -7986,13 +8035,13 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, "istanbul-lib-coverage": { "version": "3.2.2", @@ -8001,9 +8050,9 @@ "dev": true }, "istanbul-lib-instrument": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", - "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "requires": { "@babel/core": "^7.23.9", @@ -8034,27 +8083,27 @@ } }, "@babel/compat-data": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", - "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", "dev": true }, "@babel/core": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", - "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helpers": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -8071,26 +8120,26 @@ } }, "@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", "dev": true, "requires": { - "@babel/types": "^7.24.7", + "@babel/types": "^7.25.6", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" } }, "@babel/helper-compilation-targets": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", - "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dev": true, "requires": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -8103,34 +8152,6 @@ } } }, - "@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, - "@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "dev": true, - "requires": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, "@babel/helper-module-imports": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", @@ -8142,16 +8163,15 @@ } }, "@babel/helper-module-transforms": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", - "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, "requires": { - "@babel/helper-environment-visitor": "^7.24.7", "@babel/helper-module-imports": "^7.24.7", "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" } }, "@babel/helper-simple-access": { @@ -8164,19 +8184,10 @@ "@babel/types": "^7.24.7" } }, - "@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "requires": { - "@babel/types": "^7.24.7" - } - }, "@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true }, "@babel/helper-validator-identifier": { @@ -8186,19 +8197,19 @@ "dev": true }, "@babel/helper-validator-option": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", - "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true }, "@babel/helpers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", - "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", "dev": true, "requires": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" } }, "@babel/highlight": { @@ -8214,47 +8225,47 @@ } }, "@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "dev": true + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dev": true, + "requires": { + "@babel/types": "^7.25.6" + } }, "@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, "requires": { "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" } }, "@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "dev": true, "requires": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" } @@ -8293,23 +8304,23 @@ }, "dependencies": { "@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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true } } }, "browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" } }, "convert-source-map": { @@ -8319,9 +8330,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.815", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.815.tgz", - "integrity": "sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==", + "version": "1.5.22", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.22.tgz", + "integrity": "sha512-tKYm5YHPU1djz0O+CGJ+oJIvimtsCcwR2Z9w7Skh08lUdyzXY5djods3q+z2JkWdb7tCcmM//eVavSRAiaPRNg==", "dev": true }, "lru-cache": { @@ -8334,15 +8345,15 @@ } }, "node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "yallist": { @@ -8380,9 +8391,9 @@ } }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "supports-color": { @@ -8452,9 +8463,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8633,9 +8644,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8777,9 +8788,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -8973,9 +8984,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9092,9 +9103,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9192,9 +9203,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9436,9 +9447,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9552,9 +9563,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9751,9 +9762,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9880,9 +9891,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -9994,9 +10005,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10092,9 +10103,9 @@ "dev": true }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "supports-color": { @@ -10137,9 +10148,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10225,9 +10236,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10352,9 +10363,9 @@ } }, "@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -10482,7 +10493,7 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, "json5": { @@ -10837,7 +10848,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, "lodash.merge": { "version": "4.6.2", @@ -11036,7 +11047,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "memory-pager": { "version": "1.5.0", @@ -11047,7 +11058,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, "merge-stream": { "version": "2.0.0", @@ -11058,7 +11069,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, "micromatch": { "version": "4.0.5", @@ -11385,7 +11396,7 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, "negotiator": { @@ -11549,10 +11560,18 @@ } } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { "version": "1.12.0", @@ -12271,7 +12290,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { "wrappy": "1" } @@ -12361,13 +12380,30 @@ "parse-passwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==" + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=" }, "parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12376,12 +12412,12 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -12397,7 +12433,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, "pend": { "version": "1.2.0", @@ -13150,7 +13186,7 @@ "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", "optional": true, "requires": { "memory-pager": "^1.0.2" @@ -13900,7 +13936,7 @@ "strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", "dev": true }, "strip-final-newline": { @@ -14091,13 +14127,13 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, "tmp": { @@ -14115,7 +14151,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" }, "to-regex-range": { "version": "5.0.1", @@ -14142,7 +14178,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "tsconfig-paths": { "version": "3.14.2", @@ -14308,12 +14344,12 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "requires": { "escalade": "^3.1.2", @@ -14321,15 +14357,15 @@ }, "dependencies": { "escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true }, "picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true } } @@ -14346,17 +14382,17 @@ "url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { "version": "3.4.0", @@ -14381,9 +14417,9 @@ "dev": true }, "@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==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -14420,7 +14456,7 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, "walker": { "version": "1.0.8", @@ -14434,12 +14470,12 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -14557,7 +14593,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 8b88744dc..91e73f39a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "babel-plugin-module-resolver": "^5.0.0", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", + "cheerio": "^1.0.0-rc.12", "cors": "^2.8.4", "cron": "^1.8.2", "dotenv": "^5.0.1", diff --git a/requirements/emailController/addNonHgnEmailSubscription.md b/requirements/emailController/addNonHgnEmailSubscription.md new file mode 100644 index 000000000..f5748f142 --- /dev/null +++ b/requirements/emailController/addNonHgnEmailSubscription.md @@ -0,0 +1,23 @@ +# Add Non-HGN Email Subscription Function + +## Negative Cases + +1. ❌ **Returns error 400 if `email` field is missing from the request** + - Ensures that the function checks for the presence of the `email` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if the provided `email` already exists in the subscription list** + - This case checks that the function responds with a `400` status code and a message indicating that the email is already subscribed. + +3. ❌ **Returns error 500 if there is an internal error while checking the subscription list** + - Covers scenarios where there's an issue querying the `EmailSubscriptionList` collection for the provided email (e.g., database connection issues). + +4. ❌ **Returns error 500 if there is an error sending the confirmation email** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when a new email is successfully subscribed** + - Ensures that the function successfully creates a JWT token, constructs the email, and sends the subscription confirmation email to the user. + +2. ❌ **Successfully sends a confirmation email containing the correct link** + - Verifies that the generated JWT token is correctly included in the confirmation link sent to the user in the email body. diff --git a/requirements/emailController/confirmNonHgnEmailSubscription.md b/requirements/emailController/confirmNonHgnEmailSubscription.md new file mode 100644 index 000000000..d5e1367af --- /dev/null +++ b/requirements/emailController/confirmNonHgnEmailSubscription.md @@ -0,0 +1,18 @@ +# Confirm Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `token` field is missing from the request** + - (Test: `should return 400 if token is not provided`) + +2. ✅ **Returns error 401 if the provided `token` is invalid or expired** + - (Test: `should return 401 if token is invalid`) + +3. ✅ **Returns error 400 if the decoded `token` does not contain a valid `email` field** + - (Test: `should return 400 if email is missing from payload`) + +4. ❌ **Returns error 500 if there is an internal error while saving the new email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when a new email is successfully subscribed** + +2. ❌ **Returns status 200 if the email is already subscribed (duplicate email)** diff --git a/requirements/emailController/removeNonHgnEmailSubscription.md b/requirements/emailController/removeNonHgnEmailSubscription.md new file mode 100644 index 000000000..af793e2a9 --- /dev/null +++ b/requirements/emailController/removeNonHgnEmailSubscription.md @@ -0,0 +1,10 @@ +# Remove Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `email` field is missing from the request** + - (Test: `should return 400 if email is missing`) + +2. ❌ **Returns error 500 if there is an internal error while deleting the email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when an email is successfully unsubscribed** diff --git a/requirements/emailController/sendEmail.md b/requirements/emailController/sendEmail.md new file mode 100644 index 000000000..7ca9a482c --- /dev/null +++ b/requirements/emailController/sendEmail.md @@ -0,0 +1,10 @@ +# Send Email Function + +## Negative Cases + +1. ❌ **Returns error 400 if `to`, `subject`, or `html` fields are missing from the request** +2. ❌ **Returns error 500 if there is an internal error while sending the email** + +## Positive Cases + +1. ✅ **Returns status 200 when email is successfully sent with `to`, `subject`, and `html` fields provided** diff --git a/requirements/emailController/sendEmailToAll.md b/requirements/emailController/sendEmailToAll.md new file mode 100644 index 000000000..32a09fed6 --- /dev/null +++ b/requirements/emailController/sendEmailToAll.md @@ -0,0 +1,26 @@ +# Send Email to All Function + +## Negative Cases + +1. ❌ **Returns error 400 if `subject` or `html` fields are missing from the request** + - The request should be rejected if either the `subject` or `html` content is not provided in the request body. + +2. ❌ **Returns error 500 if there is an internal error while fetching users** + - This case covers scenarios where there's an error fetching users from the `userProfile` collection (e.g., database connection issues). + +3. ❌ **Returns error 500 if there is an internal error while fetching the subscription list** + - This case covers scenarios where there's an error fetching emails from the `EmailSubcriptionList` collection. + +4. ❌ **Returns error 500 if there is an error sending emails** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when emails are successfully sent to all active users** + - Ensures that the function sends emails correctly to all users meeting the criteria (`isActive` and `EmailSubcriptionList`). + +2. ❌ **Returns status 200 when emails are successfully sent to all users in the subscription list** + - Verifies that the function sends emails to all users in the `EmailSubcriptionList`, including the unsubscribe link in the email body. + +3. ❌ **Combines user and subscription list emails successfully** + - Ensures that the function correctly sends emails to both active users and the subscription list without issues. diff --git a/requirements/emailController/updateEmailSubscription.md b/requirements/emailController/updateEmailSubscription.md new file mode 100644 index 000000000..bcafa5a28 --- /dev/null +++ b/requirements/emailController/updateEmailSubscription.md @@ -0,0 +1,20 @@ +# Update Email Subscriptions Function + +## Negative Cases + +1. ❌ **Returns error 400 if `emailSubscriptions` field is missing from the request** + - This ensures that the function checks for the presence of the `emailSubscriptions` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if `email` field is missing from the requestor object** + - Ensures that the function requires an `email` field within the `requestor` object in the request body and returns `400` if it's absent. + +3. ❌ **Returns error 404 if the user with the provided `email` is not found** + - This checks that the function correctly handles cases where no user exists with the given `email` and responds with a `404` status code. + +4. ✅ **Returns error 500 if there is an internal error while updating the user profile** + - Covers scenarios where there's a database error while updating the user's email subscriptions. + +## Positive Cases + +1. ❌ **Returns status 200 and the updated user when email subscriptions are successfully updated** + - Ensures that the function updates the `emailSubscriptions` field for the user and returns the updated user document along with a `200` status code. diff --git a/requirements/informationController/addInformation.md b/requirements/informationController/addInformation.md new file mode 100644 index 000000000..d62066735 --- /dev/null +++ b/requirements/informationController/addInformation.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Add Information + +> ## Positive case +1. ✅ Returns 201 if adding new information successfullyn and no cache. +2. ✅ Returns if adding new information successfully and hascache. + +> ## Negative case +1. ✅ Returns error 500 if if there are no information in the database and any error occurs when finding the infoName. +2. ✅ Returns error 400 if if there are duplicate infoName in the database. +3. ✅ Returns error 400 if if there are issues when saving new informations. +4. ✅ Returns error 400 if if there are errors when saving the new information. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/deleteInformation.md b/requirements/informationController/deleteInformation.md new file mode 100644 index 000000000..fb5c3c867 --- /dev/null +++ b/requirements/informationController/deleteInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Delete Information + +> ## Positive case +1. ✅ Returns 200 if deleting informations successfull and no cache. +2. ✅ Returns if deleting informations successfully and has cache. + +> ## Negative case +1. ✅ Returns error 400 if if there is any error when finding the information by information id. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/getInformation.md b/requirements/informationController/getInformation.md new file mode 100644 index 000000000..bd8976a5a --- /dev/null +++ b/requirements/informationController/getInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Information + +> ## Positive case +1. ✅ Returns 200 if the informations key exists in NodeCache. +2. ✅ Returns 200 if there are information in the database. + +> ## Negative case +1. ✅ Returns error 404 if if there are no information in the database and any error occurs when finding the information. + +> ## Edge case \ No newline at end of file diff --git a/requirements/informationController/updateInformation.md b/requirements/informationController/updateInformation.md new file mode 100644 index 000000000..709bcba54 --- /dev/null +++ b/requirements/informationController/updateInformation.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Update Information + +> ## Positive case +1. ✅ Returns 200 if updating informations successfully when no cache. +2. ✅ Returns if updating informations successfully when hascache. + +> ## Negative case +1. ✅ Returns error 400 if if there is any error when finding the information by information id. + +> ## Edge case \ No newline at end of file diff --git a/requirements/popUpEditorController/createPopPopupEditor.md b/requirements/popUpEditorController/createPopPopupEditor.md new file mode 100644 index 000000000..0afcaa740 --- /dev/null +++ b/requirements/popUpEditorController/createPopPopupEditor.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# createPopPopupEditor Function + +> ### Positive case + +> 1. ✅ Should return 201 and the new pop-up editor on success + +> ### Negative case + +> 1. ✅ Should return 403 if user does not have permission to create a pop-up editor +> 2. ✅ Should return 400 if the request body is missing required fields +> 3. ✅ Should return 500 if there is an error saving the new pop-up editor to the database diff --git a/requirements/popUpEditorController/getAllPopupEditors.md b/requirements/popUpEditorController/getAllPopupEditors.md new file mode 100644 index 000000000..f42f93c1a --- /dev/null +++ b/requirements/popUpEditorController/getAllPopupEditors.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getAllPopupEditors Function + +> ## Positive case +> 1. ✅ Should return 200 and all pop-up editors on success + +> ## Negative case +> 1. ✅ Should return 404 if there is an error retrieving the pop-up editors from the database diff --git a/requirements/popUpEditorController/getPopupEditorById.md b/requirements/popUpEditorController/getPopupEditorById.md new file mode 100644 index 000000000..013096ed9 --- /dev/null +++ b/requirements/popUpEditorController/getPopupEditorById.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getPopupEditorById Function + +> ## Positive case +> 1. ✅ Should return 200 and the pop-up editor on success + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/requirements/popUpEditorController/updatePopupEditor.md b/requirements/popUpEditorController/updatePopupEditor.md new file mode 100644 index 000000000..c4e5f5904 --- /dev/null +++ b/requirements/popUpEditorController/updatePopupEditor.md @@ -0,0 +1,11 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updatePopupEditor Function + +> ## Positive case +> 1. ✅ Should return 200 and the updated pop-up editor on success + + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/requirements/reasonSchedulingController/deleteReason.md b/requirements/reasonSchedulingController/deleteReason.md new file mode 100644 index 000000000..8df5ccb16 --- /dev/null +++ b/requirements/reasonSchedulingController/deleteReason.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/reason/:userId/** route. +2. ✅ Return 200 if delete reason successfully. + +> ## Negative case +1. ✅ Returns 403 when no permission to delete. +2. ✅ Returns 404 when error in finding user Id. +3. ✅ Returns 404 when error in finding reason. +4. ✅ Returns 500 when error in deleting. + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/getAllReasons.md b/requirements/reasonSchedulingController/getAllReasons.md new file mode 100644 index 000000000..58499a41b --- /dev/null +++ b/requirements/reasonSchedulingController/getAllReasons.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getAllReasons + +> ## Positive case +1. ✅ Receives a GET request in the **/api/reason/:userId** route. +2. ✅ Return 200 if get schedule reason successfully. + +> ## Negative case +1. ✅ Returns 404 when error in finding user by Id. +2. ✅ Returns 400 when any error in fetching the user + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/getSingleReason.md b/requirements/reasonSchedulingController/getSingleReason.md new file mode 100644 index 000000000..bc81fd9d9 --- /dev/null +++ b/requirements/reasonSchedulingController/getSingleReason.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getSingleReason + +> ## Positive case +1. ✅ Receives a GET request in the **/api/reason/single/:userId** route. +2. ✅ Return 200 if not found schedule reason and return empty object successfully. +3. ✅ Return 200 if found schedule reason and return reason successfully. + +> ## Negative case +1. ✅ Returns 404 when any error in find user by Id +2. ✅ Returns 400 when any error in fetching the user + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/patchReason.md b/requirements/reasonSchedulingController/patchReason.md new file mode 100644 index 000000000..6e84a8ba7 --- /dev/null +++ b/requirements/reasonSchedulingController/patchReason.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# patchReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/breason/** route. +2. ✅ Return 200 if updated schedule reason and send blue sqaure email successfully. + +> ## Negative case +1. ✅ Returns 400 for not providing reason. +2. ✅ Returns 404 when error in finding user Id. +3. ✅ Returns 404 when not finding provided reason. +4. ✅ Returns 400 when any error in saving. + +> ## Edge case \ No newline at end of file diff --git a/requirements/reasonSchedulingController/postReason.md b/requirements/reasonSchedulingController/postReason.md new file mode 100644 index 000000000..ac8ea8f2d --- /dev/null +++ b/requirements/reasonSchedulingController/postReason.md @@ -0,0 +1,18 @@ +Check mark: ✅ +Cross Mark: ❌ + +# postReason + +> ## Positive case +1. ✅ Receives a POST request in the **/api/reason/** route. +2. ✅ Return 200 if s dchedule reason and send blue sqaure email successfully. + +> ## Negative case +1. ✅ Returns 400 for warning to choose Sunday. +2. ✅ Returns 400 for warning to choose a funture date. +3. ✅ Returns 400 for not providing reason. +4. ✅ Returns 404 when error in finding user Id. +5. ✅ Returns 403 when duplicate reason to the date. +6. ✅ Returns 400 when any error in saving. + +> ## Edge case \ No newline at end of file diff --git a/requirements/taskController/deleteTask.md b/requirements/taskController/deleteTask.md new file mode 100644 index 000000000..4f986e2a9 --- /dev/null +++ b/requirements/taskController/deleteTask.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteTask Function + +> ## Positive case +1. ✅ Returns status 200 on successful deletion. + +> ## Negative case +1. ✅ Returns status 400 if either no record is found in Task collection or some error occurs while saving the tasks. + +2. ✅ Returns status 403 if the request.body.requestor does not have `deleteTask` permission. + + +> ## Edge case diff --git a/requirements/taskController/deleteTaskByWBS.md b/requirements/taskController/deleteTaskByWBS.md new file mode 100644 index 000000000..20ef90624 --- /dev/null +++ b/requirements/taskController/deleteTaskByWBS.md @@ -0,0 +1,15 @@ +Check mark: ✅ +Cross Mark: ❌ + +# deleteTaskByWBS Function + +> ## Positive case +1. ✅ Returns status 200 on successful deletion. + +> ## Negative case +1. ✅ Returns status 400 if either no record is found in Task collection or some error occurs while saving the tasks. + +2. ✅ Returns status 403 if the request.body.requestor does not have `deleteTask` permission. + + +> ## Edge case diff --git a/requirements/taskController/fixTasks.md b/requirements/taskController/fixTasks.md new file mode 100644 index 000000000..f6a80846a --- /dev/null +++ b/requirements/taskController/fixTasks.md @@ -0,0 +1,11 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateAllParents Function + +> ## Positive case +1. ✅ Returns status 200 on without performing an operation. + +> ## Negative case + +> ## Edge case diff --git a/requirements/taskController/getTaskById.md b/requirements/taskController/getTaskById.md new file mode 100644 index 000000000..88e7e1d03 --- /dev/null +++ b/requirements/taskController/getTaskById.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTaskById Function + +> ## Positive case +1. ✅ Returns status 200 on successfully getting a taskById. + +> ## Negative case +1. ✅ Returns status 400 if either req.params.id is missing or is `undefined` or if no task is found in Task collection. + +2. ✅ Returns status 500 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getTasks.md b/requirements/taskController/getTasks.md new file mode 100644 index 000000000..8cd6f08e7 --- /dev/null +++ b/requirements/taskController/getTasks.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasks Function + +> ## Positive case +1. ✅ Returns status 200 on successfully querying the Task collection. + +> ## Negative case +1. ✅ Returns status 404 if any error occurs while querying the Task collection. + +> ## Edge case diff --git a/requirements/taskController/getTasksByUserId.md b/requirements/taskController/getTasksByUserId.md new file mode 100644 index 000000000..7f482ef07 --- /dev/null +++ b/requirements/taskController/getTasksByUserId.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasksByUserId Function + +> ## Positive case +1. ✅ Returns status 200 on successfully getting the tasks. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getTasksForTeamsByUser.md b/requirements/taskController/getTasksForTeamsByUser.md new file mode 100644 index 000000000..4eae8defa --- /dev/null +++ b/requirements/taskController/getTasksForTeamsByUser.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getTasksForTeamsByUser Function + +> ## Positive case +1. ✅ Returns status 200 on successfully fetching teams data. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/getWBSId.md b/requirements/taskController/getWBSId.md new file mode 100644 index 000000000..8efb2290a --- /dev/null +++ b/requirements/taskController/getWBSId.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getWBSId Function + +> ## Positive case +1. ✅ Returns status 200 on successfully querying the WBS collection. + +> ## Negative case +1. ✅ Returns status 404 if any error occurs while querying the WBS collection. + +> ## Edge case diff --git a/requirements/taskController/importTask.md b/requirements/taskController/importTask.md new file mode 100644 index 000000000..8626209aa --- /dev/null +++ b/requirements/taskController/importTask.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# importTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully creating and saving the new Task. + +> ## Negative case +1. ✅ Returns status 400 if any error occurs while saving the Task. + +2. ✅ Returns status 403 if request.body.requestor is missing permission for importTask. + +> ## Edge case diff --git a/requirements/taskController/moveTask.md b/requirements/taskController/moveTask.md new file mode 100644 index 000000000..68794e051 --- /dev/null +++ b/requirements/taskController/moveTask.md @@ -0,0 +1,13 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateNum Function + +> ## Positive case +1. ✅ Returns status 200 on successful updation. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.fromNum request.body.toNum is missing or some error occurs while saving the tasks. + + +> ## Edge case diff --git a/requirements/taskController/postTask.md b/requirements/taskController/postTask.md new file mode 100644 index 000000000..971b9a04a --- /dev/null +++ b/requirements/taskController/postTask.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# postTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully posting the new task. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.taskName is missing or request.body.isActive is missing or some error occurs while saving task or Wbs or project. + +2. ✅ Returns status 403 if request.body.requestor is missing permission `postTask`. + +> ## Edge case diff --git a/requirements/taskController/sendReviewReq.md b/requirements/taskController/sendReviewReq.md new file mode 100644 index 000000000..53c03ff77 --- /dev/null +++ b/requirements/taskController/sendReviewReq.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# sendReviewReq Function + +> ## Positive case +1. ✅ Returns status 200 on successful operation. + +> ## Negative case +1. ✅ Returns status 500 if some error occurs. + +> ## Edge case diff --git a/requirements/taskController/swap.md b/requirements/taskController/swap.md new file mode 100644 index 000000000..93540de92 --- /dev/null +++ b/requirements/taskController/swap.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# swap Function + +> ## Positive case +1. ✅ Returns status 201 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.taskId1 is missing or request.body.taskId2 is missing or there is error while executing findById in Task or some error occurs while saving task. + +2. ✅ Returns status 403 if request.body.requestor is missing `swapTask` permission. + +3. ✅ Returns status 404 if some error occurs while executing find operation on Task collection. + +> ## Edge case diff --git a/requirements/taskController/updateAllParents.md b/requirements/taskController/updateAllParents.md new file mode 100644 index 000000000..eccce4d41 --- /dev/null +++ b/requirements/taskController/updateAllParents.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateAllParents Function + +> ## Positive case +1. ✅ Returns status 200 on successful updation. + +> ## Negative case +1. ✅ Returns status 400 if some error occurs. - Not possible to check as per current structure + +> ## Edge case diff --git a/requirements/taskController/updateNum.md b/requirements/taskController/updateNum.md new file mode 100644 index 000000000..4b489014c --- /dev/null +++ b/requirements/taskController/updateNum.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateNum Function + +> ## Positive case +1. ✅ Returns status 200 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.nums is missing or some error occurs while saving child task. + +2. ✅ Returns status 403 if request.body.requestor is missing `updateNum` permission. + +3. ✅ Returns status 404 if some error occurs while processing the child tasks. + +> ## Edge case diff --git a/requirements/taskController/updateTask.md b/requirements/taskController/updateTask.md new file mode 100644 index 000000000..09e631603 --- /dev/null +++ b/requirements/taskController/updateTask.md @@ -0,0 +1,16 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateTask Function + +> ## Positive case +1. ✅ Returns status 201 on successfully updating. + +> ## Negative case +1. ✅ Returns status 400 if either request.body.nums is missing or some error occurs while saving child task. + +2. ✅ Returns status 403 if request.body.requestor is missing `updateTask` permission. + +3. ✅ Returns status 404 if some error occurs while executing findOneAndUpdate operation on Task collection. + +> ## Edge case diff --git a/requirements/taskController/updateTaskStatus.md b/requirements/taskController/updateTaskStatus.md new file mode 100644 index 000000000..59bdbdd7c --- /dev/null +++ b/requirements/taskController/updateTaskStatus.md @@ -0,0 +1,12 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updateTaskStatus Function + +> ## Positive case +1. ✅ Returns status 201 on successful update operation. + +> ## Negative case +1. ✅ Returns status 404 if some error occurs. + +> ## Edge case diff --git a/requirements/timeZoneAPIController/getTImeZone.md b/requirements/timeZoneAPIController/getTImeZone.md new file mode 100644 index 000000000..c7ae7801e --- /dev/null +++ b/requirements/timeZoneAPIController/getTImeZone.md @@ -0,0 +1,20 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Time Zone + +> ## Positive case + +1. ✅ Returns status code 200 and response data as follows: + i. current location + ii. timezone + +> ## Negative case + +1. ✅ Returns status code 403, if the user is not authorised. +2. ✅ Returns status code 401, if the API key is missing. +3. ✅ Returns status code 400, if the location is missing. +4. ✅ Returns status code 404, if geocodeAPIEndpoint returns no results. +5. ✅ Returns status code 500, if any other error occurs. + +> ## Edge case diff --git a/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md b/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md new file mode 100644 index 000000000..1a0f91265 --- /dev/null +++ b/requirements/timeZoneAPIController/getTimeZoneProfileInitialSetup.md @@ -0,0 +1,20 @@ +Check mark: ✅ +Cross Mark: ❌ + +# Get Time Zone + +> ## Positive case + +1. ✅ Returns status code 200 and response data as follows: + i. current location + ii. timezone + +> ## Negative case + +1. ✅ Returns status code 400, if the token is missing in the request body. +2. ✅ Returns status code 403, if the no document exists in ProfileInitialSetupToken database with requested token. +3. ✅ Returns status code 400, if the location is missing. +4. ✅ Returns status code 404, if geocodeAPIEndpoint returns no results. +5. ✅ Returns status code 500, if any other error occurs. + +> ## Edge case diff --git a/src/controllers/actionItemController.js b/src/controllers/actionItemController.js index 6d5864213..390794b31 100644 --- a/src/controllers/actionItemController.js +++ b/src/controllers/actionItemController.js @@ -1,128 +1,123 @@ -/** - * Unused legacy code. Commented out to avoid confusion. Will delete in the next cycle. - * Commented by: Shengwei Peng - * Date: 2024-03-22 - */ - -// const mongoose = require('mongoose'); -// const notificationhelper = require('../helpers/notificationhelper')(); - -// const actionItemController = function (ActionItem) { -// const getactionItem = function (req, res) { -// const userid = req.params.userId; -// ActionItem.find({ -// assignedTo: userid, -// }, ('-createdDateTime -__v')) -// .populate('createdBy', 'firstName lastName') -// .then((results) => { -// const actionitems = []; - -// results.forEach((element) => { -// const actionitem = {}; - -// actionitem._id = element._id; -// actionitem.description = element.description; -// actionitem.createdBy = `${element.createdBy.firstName} ${element.createdBy.lastName}`; -// actionitem.assignedTo = element.assignedTo; - -// actionitems.push(actionitem); -// }); - -// res.status(200).send(actionitems); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; -// const postactionItem = function (req, res) { -// const { requestorId, assignedTo } = req.body.requestor; -// const _actionItem = new ActionItem(); - -// _actionItem.description = req.body.description; -// _actionItem.assignedTo = req.body.assignedTo; -// _actionItem.createdBy = req.body.requestor.requestorId; - -// _actionItem.save() -// .then((result) => { -// notificationhelper.notificationcreated(requestorId, assignedTo, _actionItem.description); - -// const actionitem = {}; - -// actionitem.createdBy = 'You'; -// actionitem.description = _actionItem.description; -// actionitem._id = result._id; -// actionitem.assignedTo = _actionItem.assignedTo; - -// res.status(200).send(actionitem); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; - -// const deleteactionItem = async function (req, res) { -// const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); - -// const _actionItem = await ActionItem.findById(actionItemId) -// .catch((error) => { -// res.status(400).send(error); -// }); - -// if (!_actionItem) { -// res.status(400).send({ -// message: 'No valid records found', -// }); -// return; -// } - -// const { requestorId, assignedTo } = req.body.requestor; - -// notificationhelper.notificationdeleted(requestorId, assignedTo, _actionItem.description); - -// _actionItem.remove() -// .then(() => { -// res.status(200).send({ -// message: 'removed', -// }); -// }) -// .catch((error) => { -// res.status(400).send(error); -// }); -// }; - -// const editactionItem = async function (req, res) { -// const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); - -// const { requestorId, assignedTo } = req.body.requestor; - -// const _actionItem = await ActionItem.findById(actionItemId) -// .catch((error) => { -// res.status(400).send(error); -// }); - -// if (!_actionItem) { -// res.status(400).send({ -// message: 'No valid records found', -// }); -// return; -// } -// notificationhelper.notificationedited(requestorId, assignedTo, _actionItem.description, req.body.description); - -// _actionItem.description = req.body.description; -// _actionItem.assignedTo = req.body.assignedTo; - -// _actionItem.save() -// .then(res.status(200).send('Saved')) -// .catch((error) => res.status(400).send(error)); -// }; - -// return { -// getactionItem, -// postactionItem, -// deleteactionItem, -// editactionItem, - -// }; -// }; - -// module.exports = actionItemController; +const mongoose = require('mongoose'); +const notificationhelper = require('../helpers/notificationhelper')(); + +const actionItemController = function (ActionItem) { + const getactionItem = function (req, res) { + const userid = req.params.userId; + ActionItem.find({ + assignedTo: userid, + }, ('-createdDateTime -__v')) + .populate('createdBy', 'firstName lastName') + .then((results) => { + const actionitems = []; + + results.forEach((element) => { + const actionitem = {}; + + actionitem._id = element._id; + actionitem.description = element.description; + actionitem.createdBy = `${element.createdBy.firstName} ${element.createdBy.lastName}`; + actionitem.assignedTo = element.assignedTo; + + actionitems.push(actionitem); + }); + + res.status(200).send(actionitems); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const postactionItem = function (req, res) { + const { requestorId, assignedTo } = req.body.requestor; + const _actionItem = new ActionItem(); + + _actionItem.description = req.body.description; + _actionItem.assignedTo = req.body.assignedTo; + _actionItem.createdBy = req.body.requestor.requestorId; + + _actionItem.save() + .then((result) => { + notificationhelper.notificationcreated(requestorId, assignedTo, _actionItem.description); + + const actionitem = {}; + + actionitem.createdBy = 'You'; + actionitem.description = _actionItem.description; + actionitem._id = result._id; + actionitem.assignedTo = _actionItem.assignedTo; + + res.status(200).send(actionitem); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const deleteactionItem = async function (req, res) { + const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); + + const _actionItem = await ActionItem.findById(actionItemId) + .catch((error) => { + res.status(400).send(error); + }); + + if (!_actionItem) { + res.status(400).send({ + message: 'No valid records found', + }); + return; + } + + const { requestorId, assignedTo } = req.body.requestor; + + notificationhelper.notificationdeleted(requestorId, assignedTo, _actionItem.description); + + _actionItem.remove() + .then(() => { + res.status(200).send({ + message: 'removed', + }); + }) + .catch((error) => { + res.status(400).send(error); + }); + }; + + const editactionItem = async function (req, res) { + const actionItemId = mongoose.Types.ObjectId(req.params.actionItemId); + + const { requestorId, assignedTo } = req.body.requestor; + + const _actionItem = await ActionItem.findById(actionItemId) + .catch((error) => { + res.status(400).send(error); + }); + + if (!_actionItem) { + res.status(400).send({ + message: 'No valid records found', + }); + return; + } + + notificationhelper.notificationedited(requestorId, assignedTo, _actionItem.description, req.body.description); + + _actionItem.description = req.body.description; + _actionItem.assignedTo = req.body.assignedTo; + + _actionItem.save() + .then(res.status(200).send('Saved')) + .catch((error) => res.status(400).send(error)); + }; + + return { + getactionItem, + postactionItem, + deleteactionItem, + editactionItem, + }; +}; + +module.exports = actionItemController; diff --git a/src/controllers/currentWarningsController.js b/src/controllers/currentWarningsController.js new file mode 100644 index 000000000..ec76d3326 --- /dev/null +++ b/src/controllers/currentWarningsController.js @@ -0,0 +1,153 @@ +/* eslint-disable */ +const mongoose = require('mongoose'); +const userProfile = require('../models/userProfile'); + +const currentWarningsController = function (currentWarnings) { + const checkForDuplicates = (currentWarning, warnings) => { + const duplicateFound = warnings.some( + (warning) => warning.warningTitle.toLowerCase() === currentWarning, + ); + + return duplicateFound; + }; + + const checkIfSpecialCharacter = (warning) => { + return !/^[a-zA-Z][a-zA-Z0-9]*(?: [a-zA-Z0-9]+)*$/.test(warning); + }; + const getCurrentWarnings = async (req, res) => { + try { + const response = await currentWarnings.find({}); + + if (response.length === 0) { + return res.status(400).send({ message: 'no valid records' }); + } + return res.status(200).send({ currentWarningDescriptions: response }); + } catch (error) { + res.status(401).send({ message: error.message || error }); + } + }; + + const postNewWarningDescription = async (req, res) => { + try { + const { newWarning, activeWarning, isPermanent } = req.body; + + const warnings = await currentWarnings.find({}); + + if (warnings.length === 0) { + return res.status(400).send({ error: 'no valid records' }); + } + + const testWarning = checkIfSpecialCharacter(newWarning); + if (testWarning) { + return res.status(200).send({ + error: 'Warning cannot have special characters as the first letter', + }); + } + + if (checkForDuplicates(newWarning, warnings)) { + return res.status(200).send({ error: 'warning already exists' }); + } + + const newWarningDescription = new currentWarnings(); + newWarningDescription.warningTitle = newWarning; + newWarningDescription.activeWarning = activeWarning; + newWarningDescription.isPermanent = isPermanent; + + warnings.push(newWarningDescription); + await newWarningDescription.save(); + + return res.status(201).send({ newWarnings: warnings }); + } catch (error) { + return res.status(401).send({ message: error.message }); + } + }; + + const editWarningDescription = async (req, res) => { + try { + const { editedWarning } = req.body; + + const id = editedWarning._id; + + const warnings = await currentWarnings.find({}); + + if (warnings.length === 0) { + return res.status(400).send({ message: 'no valid records' }); + } + + const lowerCaseWarning = editedWarning.warningTitle.toLowerCase(); + const testWarning = checkIfSpecialCharacter(lowerCaseWarning); + + if (testWarning) { + return res.status(200).send({ + error: 'Warning cannot have special characters as the first letter', + }); + } + + if (checkForDuplicates(lowerCaseWarning, warnings)) { + return res.status(200).send({ error: 'warning already exists try a different name' }); + } + + await currentWarnings.findOneAndUpdate( + { _id: id }, + [{ $set: { warningTitle: lowerCaseWarning.trim() } }], + { new: true }, + ); + + res.status(201).send({ message: 'warning description was updated' }); + } catch (error) { + res.status(401).send({ message: error.message || error }); + } + }; + const updateWarningDescription = async (req, res) => { + try { + const { warningDescriptionId } = req.params; + + await currentWarnings.findOneAndUpdate( + { _id: warningDescriptionId }, + [{ $set: { activeWarning: { $not: '$activeWarning' } } }], + { new: true }, + ); + + res.status(201).send({ message: 'warning description was updated' }); + } catch (error) { + res.status(401).send({ message: error.message || error }); + } + }; + + const deleteWarningDescription = async (req, res) => { + try { + const { warningDescriptionId } = req.params; + const documentToDelete = await currentWarnings.findById(warningDescriptionId); + + await currentWarnings.deleteOne({ + _id: mongoose.Types.ObjectId(warningDescriptionId), + }); + + const deletedDescription = documentToDelete.warningTitle; + + await userProfile.updateMany( + { + 'warnings.description': deletedDescription, + }, + { + $pull: { + warnings: { description: deletedDescription }, + }, + }, + ); + + return res.status(200); + } catch (error) { + res.status(401).send({ message: error.message || error }); + } + }; + + return { + getCurrentWarnings, + postNewWarningDescription, + updateWarningDescription, + deleteWarningDescription, + editWarningDescription, + }; +}; +module.exports = currentWarningsController; diff --git a/src/controllers/emailController.js b/src/controllers/emailController.js index b8149edba..7eb79aaf4 100644 --- a/src/controllers/emailController.js +++ b/src/controllers/emailController.js @@ -1,15 +1,87 @@ // emailController.js const jwt = require('jsonwebtoken'); +const cheerio = require('cheerio'); const emailSender = require('../utilities/emailSender'); +const { hasPermission } = require('../utilities/permissions'); const EmailSubcriptionList = require('../models/emailSubcriptionList'); const userProfile = require('../models/userProfile'); const frontEndUrl = process.env.FRONT_END_URL || 'http://localhost:3000'; const jwtSecret = process.env.JWT_SECRET || 'EmailSecret'; +const handleContentToOC = (htmlContent) => + ` + +
+ + + + ${htmlContent} + + `; + +const handleContentToNonOC = (htmlContent, email) => + ` + + + + + + ${htmlContent} +Thank you for subscribing to our email updates!
+If you would like to unsubscribe, please click here
+ + `; + +function extractImagesAndCreateAttachments(html) { + const $ = cheerio.load(html); + const attachments = []; + + $('img').each((i, img) => { + const src = $(img).attr('src'); + if (src.startsWith('data:image')) { + const base64Data = src.split(',')[1]; + const _cid = `image-${i}`; + attachments.push({ + filename: `image-${i}.png`, + content: Buffer.from(base64Data, 'base64'), + cid: _cid, + }); + $(img).attr('src', `cid:${_cid}`); + } + }); + return { + html: $.html(), + attachments, + }; +} + const sendEmail = async (req, res) => { + const canSendEmail = await hasPermission(req.body.requestor, 'sendEmails'); + if (!canSendEmail) { + res.status(403).send('You are not authorized to send emails.'); + return; + } try { const { to, subject, html } = req.body; + // Validate required fields + if (!subject || !html || !to) { + const missingFields = []; + if (!subject) missingFields.push('Subject'); + if (!html) missingFields.push('HTML content'); + if (!to) missingFields.push('Recipient email'); + console.log('missingFields', missingFields); + return res + .status(400) + .send(`${missingFields.join(' and ')} ${missingFields.length > 1 ? 'are' : 'is'} required`); + } + + // Extract images and create attachments + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + + // Log recipient for debugging + console.log('Recipient:', to); + await emailSender(to, subject, html) .then(() => { @@ -24,48 +96,48 @@ const sendEmail = async (req, res) => { }; const sendEmailToAll = async (req, res) => { + const canSendEmailToAll = await hasPermission(req.body.requestor, 'sendEmailToAll'); + if (!canSendEmailToAll) { + res.status(403).send('You are not authorized to send emails to all.'); + return; + } try { const { subject, html } = req.body; + if (!subject || !html) { + return res.status(400).send('Subject and HTML content are required'); + } + + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + const users = await userProfile.find({ - firstName: 'Haoji', + firstName: '', email: { $ne: null }, isActive: true, emailSubscriptions: true, }); - let to = ''; - const emailContent = ` - - - - - - - - ${html} - - `; - users.forEach((user) => { - to += `${user.email},`; - }); - emailSender(to, subject, emailContent); - const emailList = await EmailSubcriptionList.find({ email: { $ne: null } }); - emailList.forEach((emailObject) => { - const { email } = emailObject; - const subscriptionEmailContent = ` - - - - - + if (users.length === 0) { + return res.status(404).send('No users found'); + } - - ${html} -Thank you for subscribing to our email updates!
-If you would like to unsubscribe, please click here
- - `; - emailSender(email, subject, subscriptionEmailContent); - }); + const recipientEmails = users.map((user) => user.email); + console.log('# sendEmailToAll to', recipientEmails.join(',')); + if (recipientEmails.length === 0) { + throw new Error('No recipients defined'); + } + const emailContentToOCmembers = handleContentToOC(processedHtml); + await Promise.all( + recipientEmails.map((email) => + emailSender(email, subject, emailContentToOCmembers, attachments), + ), + ); + const emailSubscribers = await EmailSubcriptionList.find({ email: { $exists: true, $ne: '' } }); + console.log('# sendEmailToAll emailSubscribers', emailSubscribers.length); + await Promise.all( + emailSubscribers.map(({ email }) => { + const emailContentToNonOCmembers = handleContentToNonOC(processedHtml, email); + return emailSender(email, subject, emailContentToNonOCmembers, attachments); + }), + ); return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); diff --git a/src/controllers/emailController.spec.js b/src/controllers/emailController.spec.js new file mode 100644 index 000000000..f5327a328 --- /dev/null +++ b/src/controllers/emailController.spec.js @@ -0,0 +1,146 @@ +const { mockReq, mockRes, assertResMock } = require('../test'); +const emailController = require('./emailController'); +const jwt = require('jsonwebtoken'); +const userProfile = require('../models/userProfile'); + + +jest.mock('jsonwebtoken'); +jest.mock('../models/userProfile'); +jest.mock('../utilities/emailSender'); + + + + +const makeSut = () => { + const { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + } = emailController; + return { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + }; +}; +describe('emailController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('sendEmail function', () => { + test('should send email successfully', async () => { + const { sendEmail } = makeSut(); + const mockReq = { + body: { + to: 'recipient@example.com', + subject: 'Test Subject', + html: 'Test Body
', + }, + }; + const response = await sendEmail(mockReq, mockRes); + assertResMock(200, 'Email sent successfully', response, mockRes); + }); +}); + + describe('updateEmailSubscriptions function', () => { + test('should handle error when updating email subscriptions', async () => { + const { updateEmailSubscriptions } = makeSut(); + + + userProfile.findOneAndUpdate = jest.fn(); + + userProfile.findOneAndUpdate.mockRejectedValue(new Error('Update failed')); + + const mockReq = { + body: { + emailSubscriptions: ['subscription1', 'subscription2'], + requestor: { + email: 'test@example.com', + }, + }, + }; + + const response = await updateEmailSubscriptions(mockReq, mockRes); + + assertResMock(500, 'Error updating email subscriptions', response, mockRes); + }); + }); + + + describe('confirmNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jwt.verify = jest.fn(); + }); + + test('should return 400 if token is not provided', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + + const mockReq = { body: {} }; + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + test('should return 401 if token is invalid', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'invalidToken' } }; + + jwt.verify.mockImplementation(() => { + throw new Error('Token is not valid'); + }); + + await confirmNonHgnEmailSubscription(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + errors: [ + { msg: 'Token is not valid' }, + ], + }); + }); + + + test('should return 400 if email is missing from payload', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'validToken' } }; + + // Mocking jwt.verify to return a payload without email + jwt.verify.mockReturnValue({}); + + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + + + + + }); + describe('removeNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return 400 if email is missing', async () => { + const { removeNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: {} }; + + const response = await removeNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Email is required', response, mockRes); + }); + }); + + }); diff --git a/src/controllers/informationController.js b/src/controllers/informationController.js index 792620995..03a23ba57 100644 --- a/src/controllers/informationController.js +++ b/src/controllers/informationController.js @@ -1,17 +1,17 @@ -const mongoose = require('mongoose'); +// const mongoose = require('mongoose'); // const userProfile = require('../models/userProfile'); // const hasPermission = require('../utilities/permissions'); const escapeRegex = require('../utilities/escapeRegex'); -const cache = require('../utilities/nodeCache')(); +const cacheClosure = require('../utilities/nodeCache'); const informationController = function (Information) { + const cache = cacheClosure(); const getInformations = function (req, res) { // return all informations if cache is available if (cache.hasCache('informations')) { res.status(200).send(cache.getCache('informations')); return; } - Information.find({}, 'infoName infoContent visibility') .then((results) => { // cache results diff --git a/src/controllers/informationController.spec.js b/src/controllers/informationController.spec.js new file mode 100644 index 000000000..e69dd2a32 --- /dev/null +++ b/src/controllers/informationController.spec.js @@ -0,0 +1,392 @@ +/* eslint-disable no-unused-vars */ +// const mongoose = require('mongoose'); +const mongoose = require('mongoose'); + +jest.mock('../utilities/nodeCache'); +const cache = require('../utilities/nodeCache'); +const Information = require('../models/information'); +const escapeRegex = require('../utilities/escapeRegex'); +const informationController = require('./informationController'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +/* eslint-disable no-unused-vars */ +/* eslint-disable prefer-promise-reject-errors */ + +const makeSut = () => { + const { addInformation, getInformations, updateInformation, deleteInformation } = + informationController(Information); + + return { + addInformation, + getInformations, + updateInformation, + deleteInformation, + }; +}; +// Define flushPromises function)); +const flushPromises = () => new Promise(setImmediate); + +const makeMockCache = (method, value) => { + const cacheObject = { + getCache: jest.fn(), + removeCache: jest.fn(), + hasCache: jest.fn(), + setCache: jest.fn(), + }; + + const mockCache = jest.spyOn(cacheObject, method).mockImplementationOnce(() => value); + + cache.mockImplementationOnce(() => cacheObject); + + return { mockCache, cacheObject }; +}; + +describe('informationController module', () => { + beforeEach(() => {}); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('addInformation function', () => { + test('Ensure addInformation returns 500 if any error when finding any information', async () => { + const { addInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + body: { + infoName: 'some infoName', + }, + }; + jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.reject(new Error('Error when finding'))); + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + assertResMock(500, { error: new Error('Error when finding') }, response, mockRes); + }); + test('Ensure addInformation returns 400 if duplicate info Name', async () => { + const { addInformation } = makeSut(); + const data = [{ infoName: 'test Info' }]; + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'test Info', + }, + }; + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + assertResMock( + 400, + { + error: `Info Name must be unique. Another infoName with name ${newMockReq.body.infoName} already exists. Please note that info names are case insensitive`, + }, + response, + mockRes, + ); + }); + test('Ensure addInformations returns 400 if any error when saving new Information', async () => { + const { addInformation } = makeSut(); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some Info', + }, + }; + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(true)); + jest + .spyOn(Information.prototype, 'save') + .mockImplementationOnce(() => Promise.reject(new Error('Error when saving'))); + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + assertResMock(400, new Error('Error when saving'), response, mockRes); + }); + + test('Ensure addInformation returns 201 if creating information successfully when no cache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const { addInformation } = makeSut(); + const data = { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }; + + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve([])); + jest.spyOn(Information.prototype, 'save').mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some addInfo', + infoContent: '1', + visibility: '1', + }, + }; + const response = addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(201, data, response, mockRes); + }); + test('Ensure addInformation returns 201 if creating information successfully', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id: 1}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const { addInformation } = makeSut(); + const data = [ + { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }, + ]; + + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve([])); + jest.spyOn(Information.prototype, 'save').mockImplementationOnce(() => Promise.resolve(data)); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some addInfo', + infoContent: '1', + visibility: '1', + }, + }; + addInformation(newMockReq, mockRes); + await flushPromises(); + expect(findSpy).toHaveBeenCalledWith({ + infoName: { $regex: escapeRegex(newMockReq.body.infoName), $options: 'i' }, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); + describe('getInformations function', () => { + test('Ensure getInformations returns 200 if when informations key in cache', async () => { + const data = [ + { + _id: 1, + infoName: 'infoName', + infoContent: 'infoContent', + visibility: '1', + }, + ]; + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', data); + const getCacheMock = jest.spyOn(cacheObject, 'getCache').mockImplementationOnce(() => data); + const { getInformations } = makeSut(); + + const response = getInformations(mockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(getCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, data, response, mockRes); + }); + test('Ensure getInformations returns 404 if any error when no informations key and catch error in finding', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.reject(new Error('Error when finding information'))); + const { getInformations } = makeSut(); + + const response = getInformations(mockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(findSpy).toHaveBeenCalledWith({}, 'infoName infoContent visibility'); + assertResMock(404, new Error('Error when finding information'), response, mockRes); + }); + + test('Ensure getInformations returns 200 when no informations key and no duplicated information', async () => { + const data = [ + { + infoName: 'mockAdd', + infoContent: 'mockContent', + visibility: '1', + }, + ]; + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', ''); + const findSpy = jest + .spyOn(Information, 'find') + .mockImplementationOnce(() => Promise.resolve(data)); + const setCacheMock = jest.spyOn(cacheObject, 'setCache').mockImplementationOnce(() => data); + + const { getInformations } = makeSut(); + const newMockReq = { + body: { + ...mockReq.body, + infoName: 'some getInfo', + infoContent: '1', + visibility: '1', + }, + }; + const response = getInformations(newMockReq, mockRes); + await flushPromises(); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(findSpy).toHaveBeenCalledWith({}, 'infoName infoContent visibility'); + expect(setCacheMock).toHaveBeenCalledWith('informations', data); + assertResMock(200, data, response, mockRes); + }); + }); + describe('deleteInformation function', () => { + test('Ensure deleteInformation returns 400 if any error when finding and delete information', async () => { + const errorMsg = 'Error when finding and deleting information by Id'; + const { deleteInformation } = makeSut(); + jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.reject(new Error(errorMsg))); + const response = deleteInformation(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, new Error(errorMsg), response, mockRes); + }); + test('Ensure deleteInformation returns 200 if delete information successfully no cache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const deletedData = { + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }; + const { deleteInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneDeleteSpy = jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.resolve(deletedData)); + const response = deleteInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneDeleteSpy).toHaveBeenCalledWith({ _id: deletedData.id }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, deletedData, response, mockRes); + }); + test('Ensure deleteInformation returns if delete information successfully and has cache', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id:123}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const deletedData = { + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }; + const { deleteInformation } = makeSut(); + const newMockReq = { + ...mockReq.body, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + infoName: 'deletedInfo', + infoContent: 'deleted', + visibility: '1', + }, + }; + const findOneDeleteSpy = jest + .spyOn(Information, 'findOneAndDelete') + .mockImplementationOnce(() => Promise.resolve(deletedData)); + deleteInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneDeleteSpy).toHaveBeenCalledWith({ _id: deletedData.id }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); + describe('updateInformation function', () => { + test('Ensure updateInformation returns 400 if any error when finding and update information', async () => { + const errorMsg = 'Error when finding and updating information by Id'; + const { updateInformation } = makeSut(); + jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.reject(new Error(errorMsg))); + const response = updateInformation(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, new Error(errorMsg), response, mockRes); + }); + test('Ensure updateInformation returns 200 if finding and update information successfuly when nocache', async () => { + const { mockCache: hasCacheMock } = makeMockCache('hasCache', ''); + const data = { + id: '601acda376045c7879d13a77', + infoName: 'updatedInfo', + infoContent: 'updated', + visibility: '1', + }; + const newMockReq = { + body: { + id: '601acda376045c7879d13a77', + infoName: 'oldInfo', + infoContent: 'old', + visibility: '0', + }, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneUpdateSpy = jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.resolve(data)); + const { updateInformation } = makeSut(); + const response = updateInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneUpdateSpy).toHaveBeenCalledWith({ _id: data.id }, newMockReq.body, { + new: true, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + assertResMock(200, data, response, mockRes); + }); + test('Ensure updateInformation returns if finding and update information successfuly when hascache', async () => { + const { mockCache: hasCacheMock, cacheObject } = makeMockCache('hasCache', '[{_id:123}]'); + const removeCacheMock = jest + .spyOn(cacheObject, 'removeCache') + .mockImplementationOnce(() => null); + const data = { + id: '601acda376045c7879d13a77', + infoName: 'updatedInfo', + infoContent: 'updated', + visibility: '1', + }; + const newMockReq = { + body: { + id: '601acda376045c7879d13a77', + infoName: 'oldInfo', + infoContent: 'old', + visibility: '0', + }, + params: { + ...mockReq.params, + id: '601acda376045c7879d13a77', + }, + }; + const findOneUpdateSpy = jest + .spyOn(Information, 'findOneAndUpdate') + .mockImplementationOnce(() => Promise.resolve(data)); + const { updateInformation } = makeSut(); + updateInformation(newMockReq, mockRes); + await flushPromises(); + expect(findOneUpdateSpy).toHaveBeenCalledWith({ _id: data.id }, newMockReq.body, { + new: true, + }); + expect(hasCacheMock).toHaveBeenCalledWith('informations'); + expect(removeCacheMock).toHaveBeenCalledWith('informations'); + }); + }); +}); diff --git a/src/controllers/jobsController.js b/src/controllers/jobsController.js new file mode 100644 index 000000000..65cfeafdc --- /dev/null +++ b/src/controllers/jobsController.js @@ -0,0 +1,131 @@ +const Job = require('../models/jobs'); // Import the Job model + +// Controller to fetch all jobs with pagination, search, and filtering +const getJobs = async (req, res) => { + const { page = 1, limit = 18, search = '', category = '' } = req.query; + + try { + // Validate query parameters + const pageNumber = Math.max(1, parseInt(page, 10)); // Ensure page is at least 1 + const limitNumber = Math.max(1, parseInt(limit, 10)); // Ensure limit is at least 1 + + // Build query object + const query = {}; + if (search) query.title = { $regex: search, $options: 'i' }; // Case-insensitive search + if (category) query.category = category; + + // Fetch total count for pagination metadata + const totalJobs = await Job.countDocuments(query); + + // Fetch paginated results + const jobs = await Job.find(query) + .skip((pageNumber - 1) * limitNumber) + .limit(limitNumber); + + // Prepare response + res.json({ + jobs, + pagination: { + totalJobs, + totalPages: Math.ceil(totalJobs / limitNumber), + currentPage: pageNumber, + limit: limitNumber, + hasNextPage: pageNumber < Math.ceil(totalJobs / limitNumber), + hasPreviousPage: pageNumber > 1, + }, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch jobs', details: error.message }); + } +}; + +// Controller to fetch job details by ID +const getJobById = async (req, res) => { + const { id } = req.params; + + try { + const job = await Job.findById(id); + if (!job) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(job); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch job', details: error.message }); + } +}; + +// Controller to create a new job +const createJob = async (req, res) => { + const { title, category, description, imageUrl, location, applyLink, jobDetailsLink } = req.body; + + try { + const newJob = new Job({ + title, + category, + description, + imageUrl, + location, + applyLink, + jobDetailsLink, + }); + + const savedJob = await newJob.save(); + res.status(201).json(savedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to create job', details: error.message }); + } +}; + +// Controller to update an existing job by ID +const updateJob = async (req, res) => { + const { id } = req.params; + + try { + const updatedJob = await Job.findByIdAndUpdate(id, req.body, { new: true }); + if (!updatedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(updatedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to update job', details: error.message }); + } +}; + +// Controller to delete a job by ID +const deleteJob = async (req, res) => { + const { id } = req.params; + + try { + const deletedJob = await Job.findByIdAndDelete(id); + if (!deletedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json({ message: 'Job deleted successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete job', details: error.message }); + } +}; + +const getCategories = async (req, res) => { + try { + const categories = await Job.distinct('category', {}); + + // Sort categories alphabetically + categories.sort((a, b) => a.localeCompare(b)); + + res.status(200).json({ categories }); + } catch (error) { + console.error('Error fetching categories:', error); + res.status(500).json({ message: 'Failed to fetch categories' }); + } +}; + +// Export controllers as a plain object +module.exports = { + getJobs, + getJobById, + createJob, + updateJob, + deleteJob, + getCategories, +}; diff --git a/src/controllers/logincontroller.js b/src/controllers/logincontroller.js index 3ba0203aa..794d00d70 100644 --- a/src/controllers/logincontroller.js +++ b/src/controllers/logincontroller.js @@ -63,7 +63,7 @@ const logincontroller = function () { res.status(200).send({ token }); } else { - res.status(403).send({ + res.status(404).send({ message: 'Invalid password.', }); } diff --git a/src/controllers/logincontroller.spec.js b/src/controllers/logincontroller.spec.js index 595bfe77b..995be69de 100644 --- a/src/controllers/logincontroller.spec.js +++ b/src/controllers/logincontroller.spec.js @@ -110,7 +110,7 @@ describe('logincontroller module', () => { expect(findOneSpy).toHaveBeenCalledWith({ email: mockReqModified.body.email }); assertResMock( - 403, + 404, { message: 'Invalid password.', }, diff --git a/src/controllers/ownerMessageController.js b/src/controllers/ownerMessageController.js index 1b2c30205..3f74cb112 100644 --- a/src/controllers/ownerMessageController.js +++ b/src/controllers/ownerMessageController.js @@ -1,8 +1,11 @@ +const helper = require('../utilities/permissions'); + const ownerMessageController = function (OwnerMessage) { const getOwnerMessage = async function (req, res) { try { const results = await OwnerMessage.find({}); - if (results.length === 0) { // first time initialization + if (results.length === 0) { + // first time initialization const ownerMessage = new OwnerMessage(); await ownerMessage.save(); res.status(200).send({ ownerMessage }); @@ -15,7 +18,9 @@ const ownerMessageController = function (OwnerMessage) { }; const updateOwnerMessage = async function (req, res) { - if (req.body.requestor.role !== 'Owner') { + if ( + !(await helper.hasPermission(req.body.requestor, 'editHeaderMessage')) + ) { res.status(403).send('You are not authorized to create messages!'); } const { isStandard, newMessage } = req.body; @@ -40,7 +45,9 @@ const ownerMessageController = function (OwnerMessage) { }; const deleteOwnerMessage = async function (req, res) { - if (req.body.requestor.role !== 'Owner') { + if ( + !(await helper.hasPermission(req.body.requestor, 'editHeaderMessage')) + ) { res.status(403).send('You are not authorized to delete messages!'); } try { diff --git a/src/controllers/popupEditorController.spec.js b/src/controllers/popupEditorController.spec.js new file mode 100644 index 000000000..e70b05553 --- /dev/null +++ b/src/controllers/popupEditorController.spec.js @@ -0,0 +1,163 @@ +const PopUpEditor = require('../models/popupEditor'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +jest.mock('../utilities/permissions'); + +const helper = require('../utilities/permissions'); +const popupEditorController = require('./popupEditorController'); + +const flushPromises = () => new Promise(setImmediate); + +const mockHasPermission = (value) => + jest.spyOn(helper, 'hasPermission').mockImplementationOnce(() => Promise.resolve(value)); + +const makeSut = () => { + const { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor } = + popupEditorController(PopUpEditor); + return { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor }; +}; + +describe('popupEditorController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe(`getAllPopupEditors function`, () => { + test(`Should return 200 and popup editors on success`, async () => { + const { getAllPopupEditors } = makeSut(); + const mockPopupEditors = [{ popupName: 'popup', popupContent: 'content' }]; + jest.spyOn(PopUpEditor, 'find').mockResolvedValue(mockPopupEditors); + const response = await getAllPopupEditors(mockReq, mockRes); + assertResMock(200, mockPopupEditors, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getAllPopupEditors } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'find').mockRejectedValue(error); + const response = await getAllPopupEditors(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`getPopupEditorById function`, () => { + test(`Should return 200 and popup editor on success`, async () => { + const { getPopupEditorById } = makeSut(); + const mockPopupEditor = { popupName: 'popup', popupContent: 'content' }; + jest.spyOn(PopUpEditor, 'findById').mockResolvedValue(mockPopupEditor); + const response = await getPopupEditorById(mockReq, mockRes); + assertResMock(200, mockPopupEditor, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getPopupEditorById } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'findById').mockRejectedValue(error); + const response = await getPopupEditorById(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`createPopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(false); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupName or popupContent is missing`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 400, + { error: 'popupName , popupContent are mandatory fields' }, + response, + mockRes, + ); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupName: 'popup', popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await createPopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test(`Should return 500 on error`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor.prototype, 'save').mockRejectedValue(error); + const response = await createPopupEditor(mockReq, mockRes); + await flushPromises(); + + assertResMock(500, { error }, response, mockRes); + }); + }); + describe(`updatePopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(false); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupContent is missing`, async () => { + const { updatePopupEditor } = makeSut(); + mockReq.body = {}; + mockHasPermission(true); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock(400, { error: 'popupContent is mandatory field' }, response, mockRes); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor, 'findById').mockImplementationOnce((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test('Should return 500 on popupEditor save error', async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + const err = new Error('Test Error'); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockRejectedValue(err)}; + jest + .spyOn(PopUpEditor, 'findById') + .mockImplementation((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + await flushPromises(); + assertResMock(500, {err}, response, mockRes); + }); + }); +}); diff --git a/src/controllers/profileInitialSetupController.js b/src/controllers/profileInitialSetupController.js index fcf24ce1a..e56e7e406 100644 --- a/src/controllers/profileInitialSetupController.js +++ b/src/controllers/profileInitialSetupController.js @@ -172,13 +172,22 @@ const profileInitialSetupController = function ( const link = `${baseUrl}/ProfileInitialSetup/${savedToken.token}`; await session.commitTransaction(); - const acknowledgment = await sendEmailWithAcknowledgment( - email, - 'NEEDED: Complete your One Community profile setup', - sendLinkMessage(link), - ); - - return res.status(200).send(acknowledgment); + // Send response immediately without waiting for email acknowledgment + res.status(200).send({ message: 'Token created successfully, email is being sent.' }); + + // Asynchronously send the email acknowledgment + setImmediate(async () => { + try { + await sendEmailWithAcknowledgment( + email, + 'NEEDED: Complete your One Community profile setup', + sendLinkMessage(link), + ); + } catch (emailError) { + // Log email sending failure + LOGGER.logException(emailError, 'sendEmailWithAcknowledgment', JSON.stringify({ email, link }), null); + } + }); } catch (error) { await session.abortTransaction(); LOGGER.logException(error, 'getSetupToken', JSON.stringify(req.body), null); @@ -523,30 +532,26 @@ const profileInitialSetupController = function ( */ const getSetupInvitation = (req, res) => { const { role } = req.body.requestor; - if (role === 'Administrator' || role === 'Owner') { - try { - ProfileInitialSetupToken.find({ isSetupCompleted: false }) - .sort({ createdDate: -1 }) - .exec((err, result) => { - // Handle the result - if (err) { - LOGGER.logException(err); - return res - .status(500) - .send( - 'Internal Error: Please retry. If the problem persists, please contact the administrator', - ); - } - return res.status(200).send(result); - }); - } catch (error) { - LOGGER.logException(error); - return res - .status(500) - .send( - 'Internal Error: Please retry. If the problem persists, please contact the administrator', - ); - } + + const { permissions } = req.body.requestor; + let user_permissions = ['getUserProfiles','postUserProfile','putUserProfile','changeUserStatus'] + if ((role === 'Administrator') || (role === 'Owner') || (role === 'Manager') || (role === 'Mentor') || user_permissions.some(e=>permissions.frontPermissions.includes(e))) { + try{ + ProfileInitialSetupToken + .find({ isSetupCompleted: false }) + .sort({ createdDate: -1 }) + .exec((err, result) => { + // Handle the result + if (err) { + LOGGER.logException(err); + return res.status(500).send('Internal Error: Please retry. If the problem persists, please contact the administrator'); + } + return res.status(200).send(result); + }); + } catch (error) { + LOGGER.logException(error); + return res.status(500).send('Internal Error: Please retry. If the problem persists, please contact the administrator'); + } } else { return res.status(403).send('You are not authorized to get setup history.'); } diff --git a/src/controllers/projectController.js b/src/controllers/projectController.js index 72522ba80..3e48b3f42 100644 --- a/src/controllers/projectController.js +++ b/src/controllers/projectController.js @@ -291,12 +291,21 @@ const projectController = function (Project) { res.status(400).send('Invalid request'); return; } - const getId = await hasPermission(req.body.requestor, 'getProjectMembers'); + const getProjMembers = await hasPermission(req.body.requestor, 'getProjectMembers'); + + // If a user has permission to post, edit, or suggest tasks, they also have the ability to assign resources to those tasks. + // Therefore, the _id field must be included when retrieving the user profile for project members (resources). + const postTask = await hasPermission(req.body.requestor, 'postTask'); + const updateTask = await hasPermission(req.body.requestor, 'updateTask'); + const suggestTask = await hasPermission(req.body.requestor, 'suggestTask'); + + const canGetId = (getProjMembers || postTask || updateTask || suggestTask); + userProfile .find( { projects: projectId }, - { firstName: 1, lastName: 1, isActive: 1, profilePic: 1, _id: getId }, + { firstName: 1, lastName: 1, isActive: 1, profilePic: 1, _id: canGetId }, ) .sort({ firstName: 1, lastName: 1 }) .then((results) => { diff --git a/src/controllers/reasonSchedulingController.spec.js b/src/controllers/reasonSchedulingController.spec.js new file mode 100644 index 000000000..e58c77466 --- /dev/null +++ b/src/controllers/reasonSchedulingController.spec.js @@ -0,0 +1,626 @@ +const moment = require('moment-timezone'); +const { mockReq, mockRes, mockUser } = require('../test'); +const UserModel = require('../models/userProfile'); + +jest.mock('../utilities/emailSender'); +const emailSender = require('../utilities/emailSender') + +const { + postReason, + getAllReasons, + getSingleReason, + patchReason, + deleteReason, +} = require('./reasonSchedulingController'); + +// assertResMock +const ReasonModel = require('../models/reason'); + +const flushPromises = () => new Promise(setImmediate); + +function mockDay(dayIdx, past = false) { + const date = moment().tz('America/Los_Angeles').startOf('day'); + while (date.day() !== dayIdx) { + date.add(past ? -1 : 1, 'days'); + } + return date; +} + +describe('reasonScheduling Controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRes.json = jest.fn(); + mockReq.body = { + ...mockReq.body, + ...mockUser(), + reasonData: { + date: mockDay(0), + message: 'some reason', + }, + currentDate: moment.tz('America/Los_Angeles').startOf('day'), + }; + mockReq.params = { + ...mockReq.params, + ...mockUser(), + }; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('postReason method', () => { + test('Ensure postReason returns 400 for warning to choose Sunday', async () => { + mockReq.body.reasonData.date = mockDay(1, true); + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: + "You must choose the Sunday YOU'LL RETURN as your date. This is so your reason ends up as a note on that blue square.", + errorCode: 0, + }), + ); + }); + test('Ensure postReason returns 400 for warning to choose a future date', async () => { + mockReq.body.reasonData.date = mockDay(0, true); + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You should select a date that is yet to come', + errorCode: 7, + }), + ); + }); + test('Ensure postReason returns 400 for not providing reason', async () => { + mockReq.body.reasonData.message = null; + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must provide a reason.', + errorCode: 6, + }), + ); + }); + test('Ensure postReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => + Promise.resolve(null)); + + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure postReason returns 403 when duplicate reason to the date', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + const mockReason = { + reason: 'Some Reason', + userId: mockReq.body.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(mockReason); + + await postReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'The reason must be unique to the date', + errorCode: 3, + }), + ); + }); + test('Ensure postReason returns 400 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + mockRes.sendStatus = jest.fn().mockReturnThis(); + const newReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(); + const mockSave = jest.spyOn(ReasonModel.prototype, 'save').mockRejectedValue(newReason); + emailSender.mockImplementation(() => { + throw new Error('Failed to send email'); + }); + + await postReason(mockReq, mockRes); + await flushPromises(); + emailSender.mockRejectedValue(new Error('Failed')); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockSave).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + errMessage: 'Something went wrong', + }), + ); + }); + test('Ensure postReason returns 200 if schedule reason and send blue sqaure email successfully', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + jest.spyOn(UserModel, 'findOneAndUpdate').mockResolvedValueOnce({ + _id: mockReq.body.userId, + timeOffFrom: mockReq.body.currentDate, + timeOffTill: mockReq.body.reasonData.date, + }); + mockRes.sendStatus = jest.fn().mockReturnThis(); + const newReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(); + const mockSave = jest.spyOn(ReasonModel.prototype, 'save').mockResolvedValue(newReason); + emailSender.mockImplementation(() => { + Promise.resolve(); + }); + await postReason(mockReq, mockRes); + await flushPromises(); + expect(mockRes.sendStatus).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.body.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.body.userId, + }); + expect(mockSave).toHaveBeenCalledWith(); + }); + }); + describe('getAllReason method', () => { + test('Ensure get AllReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + }), + ); + }); + test('Ensure get AllReason returns 400 when any error in fetching the user', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const mockFoundReason = jest.spyOn(ReasonModel, 'find').mockRejectedValueOnce(null); + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + errMessage: 'Something went wrong while fetching the user', + }), + ); + }); + test('Ensure get AllReason returns 200 when get schedule reason successfully', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const reasons = { + reason: 'Some Reason', + userId: mockReq.params.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + isSet: true, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'find').mockResolvedValue(reasons); + await getAllReasons(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + reasons, + }), + ); + }); + }); + describe('getSingleReason method', () => { + test('Ensure getSingleReason return 400 when any error in fetching the user', async () => { + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Something went wrong while fetching single reason', + }), + ); + }); + test('Ensure getSingleReason return 404 when any error in find user by Id', async () => { + mockReq.query = { + queryData: mockDay(0), + }; + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure getSingleReason return 200 if not found schedule reason and return empty object successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + mockReq.query = { + queryDate: mockDay(0), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.query.queryDate, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith({ + reason: '', + date: '', + userId: '', + isSet: false, + }); + }); + test('Ensure getSingleReason return 200 if found schedule reason and return reason successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + mockReq.query = { + queryDate: mockDay(0), + }; + const singleReason = { + reason: 'Some Reason', + userId: mockReq.params.userId, + date: moment.tz('America/Los_Angeles').startOf('day').toISOString(), + isSet: true, + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValue(singleReason); + + await getSingleReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.query.queryDate, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith(singleReason); + }); + }); + describe('patchReason method', () => { + test('Ensure patchReason returns 400 for not providing reason', async () => { + mockReq.body.reasonData.message = null; + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must provide a reason.', + errorCode: 6, + }), + ); + }); + test('Ensure patchReason returns 404 when error in finding user Id', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure patchReason returns 404 when error in finding reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason not found', + errorCode: 4, + }), + ); + }); + test('Ensure patchReason returns 400 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const oldReason = { + reason: 'old message', + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + save: jest.fn().mockRejectedValueOnce(), + }; + emailSender.mockImplementation(() => { + throw new Error('Failed to send email'); + }); + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(oldReason); + await patchReason(mockReq, mockRes); + await flushPromises(); + emailSender.mockRejectedValue(new Error('Failed')); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(oldReason.save).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'something went wrong while patching the reason', + }), + ); + }); + test('Ensure patchReason returns 200 when any error in saving.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const oldReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + save: jest.fn().mockResolvedValueOnce(), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(oldReason); + emailSender.mockImplementation(() => { + Promise.resolve(); + }); + await patchReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + }); + expect(oldReason.save).toHaveBeenCalledWith(); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason Updated!', + }), + ); + }); + }); + describe('deleteReason method', () => { + test('Ensure deleteReason return 403 when no permission to delete', async () => { + const newMockReq = { + ...mockReq, + body: { + ...mockReq.body, + ...mockReq.requestor, + requestor: { + role: 'Volunteer', + }, + }, + }; + await deleteReason(newMockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You must be an Owner or Administrator to schedule a reason for a Blue Square', + + errorCode: 1, + }), + ); + }); + test('Ensure deleteReason return 404 when not finding user by ID', async () => { + const mockFindUser = jest.spyOn(UserModel, 'findById').mockImplementationOnce(() => null); + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User not found', + errorCode: 2, + }), + ); + }); + test('Ensure deleteReason returns 404 when error in finding reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockResolvedValueOnce(); + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Reason not found', + errorCode: 4, + }), + ); + }); + test('Ensure deleteReason returns 500 when error in removing reason', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const foundReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + remove: jest.fn((cb) => cb(true)), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockReturnValueOnce(foundReason); + + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error while deleting document', + errorCode: 5, + }), + ); + }); + test('Ensure deleteReason returns 200 if delete reason successfully.', async () => { + const mockFindUser = jest + .spyOn(UserModel, 'findById') + .mockImplementationOnce(() => mockUser()); + const foundReason = { + reason: mockReq.body.reasonData.message, + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + userId: mockReq.params.userId, + remove: jest.fn((cb) => cb(false)), + }; + const mockFoundReason = jest.spyOn(ReasonModel, 'findOne').mockReturnValueOnce(foundReason); + + await deleteReason(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockFindUser).toHaveBeenCalledWith(mockReq.params.userId); + expect(mockFoundReason).toHaveBeenCalledWith({ + date: moment + .tz(mockReq.body.reasonData.date, 'America/Los_Angeles') + .startOf('day') + .toISOString(), + }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Document deleted', + }), + ); + }); + }); +}); diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js index 1e019ffa1..b4d71a5dd 100644 --- a/src/controllers/taskController.js +++ b/src/controllers/taskController.js @@ -683,7 +683,10 @@ const taskController = function (Task) { }; const updateTask = async (req, res) => { - if (!(await hasPermission(req.body.requestor, 'updateTask'))) { + if ( + !(await hasPermission(req.body.requestor, 'updateTask')) && + !(await hasPermission(req.body.requestor, 'removeUserFromTask')) + ) { res.status(403).send({ error: 'You are not authorized to update Task.' }); return; } diff --git a/src/controllers/taskController.spec.js b/src/controllers/taskController.spec.js new file mode 100644 index 000000000..7d1196df7 --- /dev/null +++ b/src/controllers/taskController.spec.js @@ -0,0 +1,1555 @@ +const mongoose = require('mongoose'); + +// Utility to aid in testing +jest.mock('../utilities/permissions', () => ({ + hasPermission: jest.fn(), +})); + +jest.mock('../utilities/emailSender', () => jest.fn()); + +const taskHelperMethods = { + getTasksForTeams: jest.fn(), + getTasksForSingleUser: jest.fn(), +}; +jest.mock('../helpers/taskHelper', () => () => ({ ...taskHelperMethods })); + +const flushPromises = () => new Promise(setImmediate); +const { mockReq, mockRes, assertResMock } = require('../test'); +const { hasPermission } = require('../utilities/permissions'); +const emailSender = require('../utilities/emailSender'); + +// controller to test +const taskController = require('./taskController'); + +// MongoDB Model imports +const Task = require('../models/task'); +const Project = require('../models/project'); +const UserProfile = require('../models/userProfile'); +const WBS = require('../models/wbs'); +const FollowUp = require('../models/followUp'); + +const makeSut = () => { + const { + getTasks, + getWBSId, + importTask, + postTask, + updateNum, + moveTask, + deleteTask, + deleteTaskByWBS, + updateTask, + swap, + getTaskById, + fixTasks, + updateAllParents, + getTasksByUserId, + sendReviewReq, + getTasksForTeamsByUser, + updateTaskStatus, + } = taskController(Task); + + return { + getTasks, + getWBSId, + importTask, + postTask, + updateNum, + moveTask, + deleteTask, + deleteTaskByWBS, + updateTask, + swap, + getTaskById, + fixTasks, + updateAllParents, + getTasksByUserId, + sendReviewReq, + getTasksForTeamsByUser, + updateTaskStatus, + }; +}; + +describe('Unit Tests for taskController.js', () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + describe('getTasks function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getTasks } = makeSut(); + const mockData = 'some random data'; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockData); + + const response = await getTasks(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalledTimes(1); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getTasks } = makeSut(); + const error = 'some random error'; + + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(error); + + const response = await getTasks(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getWBSId function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getWBSId } = makeSut(); + const mockData = 'some random data'; + + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValueOnce(mockData); + + const response = await getWBSId(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalledTimes(1); + }); + + test('Returns 200 on successfully querying the document', async () => { + const { getWBSId } = makeSut(); + const error = 'some random error'; + + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockRejectedValueOnce(error); + + const response = await getWBSId(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('importTasks function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `importTask` permission is missing', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new Task.' }; + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 201 on successful import operation', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params.wbs = 'wbs123'; + mockReq.body.list = [ + { + _id: 'mongoDB-Id', + num: '1', + level: 1, + parentId1: null, + parentId2: null, + parentId3: null, + mother: null, + resources: ['parth|userId123|parthProfilePic', 'test|test123|testProfilePic'], + }, + ]; + + const saveMock = jest + .fn() + .mockImplementation(() => Promise.resolve({ _id: '1', wbsId: 'wbs123' })); + const TaskConstructorSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(saveMock); + + const data = 'done'; + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(201, data, response, mockRes); + expect(TaskConstructorSpy).toBeCalled(); + }); + + test('Return 400 on encountering any error while saving task', async () => { + const { importTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params.wbs = 'wbs123'; + mockReq.body.list = [ + { + _id: 'mongoDB-Id', + num: '1', + level: 1, + parentId1: null, + parentId2: null, + parentId3: null, + mother: null, + resources: ['parth|userId123|parthProfilePic', 'test|test123|testProfilePic'], + }, + ]; + + const error = new Error('error while saving'); + + const saveMock = jest.fn().mockImplementation(() => Promise.reject(error)); + const TaskConstructorSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(saveMock); + + const response = await importTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(TaskConstructorSpy).toBeCalled(); + }); + }); + + describe('postTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `postTask` permission is missing', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new Task.' }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test.each([ + [ + { taskName: undefined, isActive: true }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + [ + { taskName: 'some task name', isActive: undefined }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + [ + { taskName: undefined, isActive: undefined }, + 'Task Name, Active status, Task Number are mandatory fields', + ], + ])('Return 400 if any required field is missing', async (body, expectedError) => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + // Set the request body based on the current test case + mockReq.body.taskName = body.taskName; + mockReq.body.isActive = body.isActive; + + const error = { error: expectedError }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 201 on successfully saving a new Task', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const newTask = { + taskName: 'Sample Task', + wbsId: new mongoose.Types.ObjectId(), + num: '1', + level: 1, + position: 1, + childrenQty: 0, + isActive: true, + }; + + // Mock the current datetime + const currentDate = Date.now(); + + // Mock Task model + const mockTask = { + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + wbsId: new mongoose.Types.ObjectId(), + createdDatetime: currentDate, + modifiedDatetime: currentDate, + }), + }; + const taskSaveSpy = jest.spyOn(Task.prototype, 'save').mockResolvedValue(mockTask); + + // Mock WBS model + const mockWBS = { + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + }), + }; + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockWBS); + + // Mock Project model + const mockProjectObj = { + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + modifiedDatetime: currentDate, + }), + modifiedDatetime: currentDate, + }; + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockProjectObj); + + // add the necessary request params + mockReq.params = { + ...mockReq.params, + id: new mongoose.Types.ObjectId(), + }; + + // add the necessary body parameters + mockReq.body = { + ...mockReq.body, + ...newTask, + }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(201, expect.anything(), response, mockRes); + expect(taskSaveSpy).toBeCalled(); + expect(wbsFindByIdSpy).toBeCalled(); + expect(projectFindByIdSpy).toBeCalled(); + }); + + test('Return 400 on encountering any error during Promise.all', async () => { + const { postTask } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const newTask = { + taskName: 'Sample Task', + wbsId: new mongoose.Types.ObjectId(), + num: '1', + level: 1, + position: 1, + childrenQty: 0, + isActive: true, + }; + + // Mock the current datetime + const currentDate = Date.now(); + + // Mock the Task model + const mockTaskError = new Error('Failed to save task'); + + // Use jest.fn() to mock the save method to reject with an error + const taskSaveMock = jest.fn().mockRejectedValue(mockTaskError); + + // Spy on the Task prototype's save method + const taskSaveSpy = jest.spyOn(Task.prototype, 'save').mockImplementation(taskSaveMock); + + // Mock WBS model + const mockWBS = { + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + save: jest.fn().mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + projectId: 'projectId', + modifiedDatetime: Date.now(), + }), + }; + // Mock `WBS.findById` to return `mockWBS` + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockWBS); + + // Mock Project model + const mockProjectObj = { + save: jest.fn().mockResolvedValueOnce({ + _id: new mongoose.Types.ObjectId(), + modifiedDatetime: currentDate, + }), + modifiedDatetime: currentDate, + }; + const projectFindByIdSpy = jest + .spyOn(Project, 'findById') + .mockResolvedValueOnce(mockProjectObj); + + // add the necessary request params + mockReq.params = { + ...mockReq.params, + id: new mongoose.Types.ObjectId(), + }; + + // add the necessary body parameters + mockReq.body = { + ...mockReq.body, + ...newTask, + }; + + const response = await postTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, mockTaskError, response, mockRes); + expect(taskSaveSpy).toBeCalled(); + expect(wbsFindByIdSpy).toBeCalled(); + expect(projectFindByIdSpy).toBeCalled(); + }); + }); + + describe('updateNum function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `updateNum` permission is missing', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new projects.' }; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if `nums` is missing from the request body', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + const error = { error: 'Num is a mandatory fields' }; + mockReq.body.nums = null; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 200 on successful update - nums is empty array', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = []; + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, true, response, mockRes); + }); + + test('Return 200 on successful update - nums is not an empty array', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockResolvedValue({}), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockDataForTaskFindSpy = []; + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockDataForTaskFindSpy); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, true, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + + test('Return 404 if error occurs on Task.find()', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockResolvedValue({}), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockError = new Error({ error: 'some error occurred' }); + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(mockError); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, mockError, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + + test('Return 400 if error occurs while saving a Task within Task.findById()', async () => { + const { updateNum } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.nums = [ + { + id: 'sample-id', + num: 'sample-num', + }, + ]; + + const mockError = new Error({ error: 'some error occurred' }); + + const mockDataForTaskFindByIdSpy = { + num: 0, + save: jest.fn().mockRejectedValueOnce(mockError), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + callback(null, mockDataForTaskFindByIdSpy); + }); + + const mockDataForTaskFindSpy = []; + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockDataForTaskFindSpy); + + const response = await updateNum(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, mockError, response, mockRes); + expect(taskFindSpy).toBeCalled(); + expect(taskFindByIdSpy).toBeCalled(); + }); + }); + + describe('moveTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 400 if either `fromNum` or `toNum` is missing in request body', async () => { + const { moveTask } = makeSut(); + + const error = { error: 'wbsId, fromNum, toNum are mandatory fields' }; + mockReq.body.fromNum = null; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 200 on successful exeecution', async () => { + const { moveTask } = makeSut(); + + const requestData = { + body: { + fromNum: '1.0', + toNum: '2.0', + }, + }; + + mockReq.body = { + ...mockReq.body, + ...requestData.body, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { num: '1.0', save: jest.fn().mockResolvedValue({}) }, + { num: '1.1', save: jest.fn().mockResolvedValue({}) }, + ]); + + mockReq.params.wbsId = 'someWbsId'; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, 'Success!', response, mockRes); + expect(taskFindSpy).toBeCalled(); + }); + + test('Return 400 on some error', async () => { + const { moveTask } = makeSut(); + + const requestData = { + body: { + fromNum: '1.0', + toNum: '2.0', + }, + }; + + mockReq.body = { + ...mockReq.body, + ...requestData.body, + }; + + const error = new Error({ error: 'some error' }); + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { num: '1.0', save: jest.fn().mockResolvedValue({}) }, + { num: '1.1', save: jest.fn().mockRejectedValueOnce(error) }, + ]); + + mockReq.params.wbsId = 'someWbsId'; + + const response = await moveTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toBeCalled(); + }); + }); + + describe('deleteTask function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `deleteTask` permission is missing', async () => { + const { deleteTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to deleteTasks.' }; + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if no Task found', async () => { + const { deleteTask } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + mother: 'null', + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([]); + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + + test('Return 200 on successfully deleting task', async () => { + const { deleteTask } = makeSut(); + + const message = { message: 'Task successfully deleted' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + mother: 'null', + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValue([ + { + remove: jest.fn().mockImplementation(() => Promise.resolve(1)), + }, + ]); + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + }); + + describe('deleteTaskByWBS function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `deleteTask` permission is missing', async () => { + const { deleteTaskByWBS } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to deleteTasks.' }; + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if no Task found', async () => { + const { deleteTaskByWBS } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(null, []); + return { + catch: jest.fn(), + }; + }); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 400 if Task.find fails', async () => { + const { deleteTaskByWBS } = makeSut(); + + const expectedError = new Error('Database error'); + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(expectedError, null); + return { + catch: jest.fn((catchCallback) => { + catchCallback(expectedError); + return Promise.resolve(); + }), + }; + }); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, expectedError, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 200 on successfully deleting task', async () => { + const { deleteTaskByWBS } = makeSut(); + + const message = { message: ' Tasks were successfully deleted' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + wbsId: 456, + }; + + const taskFindSpy = jest.spyOn(Task, 'find').mockImplementation((query, callback) => { + callback(null, [ + { + remove: jest.fn().mockImplementation(() => Promise.resolve(1)), + }, + ]); + return { + catch: jest.fn(), + }; + }); + + const followUpFindOneAndDeleteSpy = jest + .spyOn(FollowUp, 'findOneAndDelete') + .mockResolvedValue(true); + + const response = await deleteTaskByWBS(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindSpy).toHaveBeenCalled(); + expect(followUpFindOneAndDeleteSpy).toHaveBeenCalled(); + }); + }); + + describe('updateTask function()', () => { + const mockedTask = { + wbs: 111, + }; + const mockedWBS = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + const mockedProject = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `updateTask` permission is missing', async () => { + const { updateTask } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to update Task.' }; + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 200 on successful update', async () => { + const { updateTask } = makeSut(); + + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockResolvedValueOnce(true); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + // assertResMock(201, null, response, mockRes); + expect(mockRes.status).toBeCalledWith(201); + expect(response).toBeUndefined(); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + + test('Return 404 on encountering error', async () => { + const { updateTask } = makeSut(); + + const error = { error: 'No valid records found' }; + hasPermission.mockResolvedValueOnce(true); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockRejectedValueOnce(error); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTask(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + }); + + describe('swap function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Return 403 if `swapTask` permission is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(false); + + const error = { error: 'You are not authorized to create new projects.' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(403, error, response, mockRes); + }); + + test('Return 400 if `taskId1` is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = null; + mockReq.body.taskId2 = 'some-value'; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if `taskId2` is missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'some-value'; + mockReq.body.taskId2 = null; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if `taskId1` and `taskId2` are missing', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = null; + mockReq.body.taskId2 = null; + + const error = { error: 'taskId1 and taskId2 are mandatory fields' }; + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Return 400 if no task exists with the id same as `taskId1`', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'invalid-taskId1'; + mockReq.body.taskId2 = 'some value'; + + const error = 'No valid records found'; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'invalid-taskId1') { + callback(null, null); // the first null shows no error | second null show no task1 + } else if (id === 'invalid-taskId2') { + callback(null, 'some task2 exists'); + } + }); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Return 400 if no task exists with the id same as `taskId2`', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'invalid-taskId2'; + + const error = 'No valid records found'; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, { _id: 'valid-taskId1', name: 'Task 1' }); + } + + if (id === 'invalid-taskId2') { + callback(null, null); // the first null shows no error | second null show no task2 found + } + }); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalledTimes(2); + expect(taskFindByIdSpy).toHaveBeenNthCalledWith(1, 'valid-taskId1', expect.any(Function)); + expect(taskFindByIdSpy).toHaveBeenNthCalledWith(2, 'invalid-taskId2', expect.any(Function)); + }); + + test('Return 400 if some error occurs while saving task1', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockRejectedValue(error), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue('sadasd'), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce('works fine'); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 400 if some error occurs while saving task2', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockRejectedValue(error), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce('works fine'); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 404 if some error occurs while saving task.find', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const error = 'some error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockRejectedValueOnce(error); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + + test('Return 200 if swapped correctly', async () => { + const { swap } = makeSut(); + hasPermission.mockResolvedValueOnce(true); + + mockReq.body.taskId1 = 'valid-taskId1'; + mockReq.body.taskId2 = 'valid-taskId2'; + + const message = 'no error'; + + const validTask1 = { + _id: 'valid-taskId1', + name: 'Task 1', + num: 1, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const validTask2 = { + _id: 'valid-taskId2', + name: 'Task 2', + num: 2, + parentId: 'pId', + save: jest.fn().mockResolvedValue(), + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockImplementation((id, callback) => { + if (id === 'valid-taskId1') { + callback(null, validTask1); + } + if (id === 'valid-taskId2') { + callback(null, validTask2); + } + }); + + const taskFindSpy = jest.spyOn(Task, 'find').mockResolvedValueOnce(message); + + const response = await swap(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, message, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindSpy).toHaveBeenCalled(); + }); + }); + + describe('getTaskById function()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 400 if the taskId is missing from the params', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = null; + + const error = { error: 'Task ID is missing' }; + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + }); + + test('Returns 400 if the taskId is missing from the params', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const error = { error: 'This is not a valid task' }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValueOnce(null); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 500 if some error occurs at Task.findById', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const error = new Error('some error occurred'); + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockRejectedValueOnce(error); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock( + 500, + { error: 'Internal Server Error', details: error.message }, + response, + mockRes, + ); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 200 if some error occurs at Task.findById', async () => { + const { getTaskById } = makeSut(); + + mockReq.params.id = 'someTaskId'; + + const mockTask = { + resources: [], + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValueOnce(mockTask); + + const response = await getTaskById(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockTask, response, mockRes); + expect(taskFindByIdSpy).toHaveBeenCalled(); + }); + }); + + describe('fixTasks function()', () => { + test('Returns 200 without performing any action', async () => { + const { fixTasks } = makeSut(); + + const response = fixTasks(mockReq, mockRes); + + await flushPromises(); + + assertResMock(200, 'done', response, mockRes); + }); + }); + + describe('updateAllParents function()', () => { + test('Returns 200 Task.Find() on successful operation', async () => { + const { updateAllParents } = makeSut(); + + const mockTasks = []; + + const taskFind = jest.spyOn(Task, 'find').mockResolvedValueOnce(mockTasks); + const response = updateAllParents(mockReq, mockRes); + + await flushPromises(); + + assertResMock(200, 'done', response, mockRes); + expect(taskFind).toHaveBeenCalled(); + }); + + test('Returns 400 on some error', async () => { + const { updateAllParents } = makeSut(); + + const error = new Error('some error'); + + const taskFind = jest.spyOn(Task, 'find').mockImplementationOnce(() => { + throw error; + }); + const response = await updateAllParents(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, error, response, mockRes); + expect(taskFind).toHaveBeenCalled(); + }); + }); + + describe('getTasksByUserId function()', () => { + test('Returns 200 and tasks when aggregation is successful', async () => { + const { getTasksByUserId } = makeSut(); + + mockReq.params.userId = '507f1f77bcf86cd799439011'; + + const mockTasks = [ + { _id: 'task1', taskName: 'Task 1', wbsName: 'WBS 1', projectName: 'Project 1' }, + { _id: 'task2', taskName: 'Task 2', wbsName: 'WBS 2', projectName: 'Project 2' }, + ]; + + // Mock the Task.aggregate method + const mockAggregate = { + match: jest.fn().mockReturnThis(), + lookup: jest.fn().mockReturnThis(), + unwind: jest.fn().mockReturnThis(), + addFields: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + }; + + mockAggregate.project.mockResolvedValue(mockTasks); + + const taskAggregate = jest.spyOn(Task, 'aggregate').mockReturnValue(mockAggregate); + + const response = await getTasksByUserId(mockReq, mockRes); + + assertResMock(200, mockTasks, response, mockRes); + expect(taskAggregate).toHaveBeenCalled(); + }); + + test('Returns 400 when error occurs', async () => { + const { getTasksByUserId } = makeSut(); + + mockReq.params.userId = '507f1f77bcf86cd799439011'; + + const mockError = new Error('some error'); + + // Mock the Task.aggregate method + const mockAggregate = { + match: jest.fn().mockReturnThis(), + lookup: jest.fn().mockReturnThis(), + unwind: jest.fn().mockReturnThis(), + addFields: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + }; + + mockAggregate.project.mockRejectedValueOnce(mockError); + + const taskAggregate = jest.spyOn(Task, 'aggregate').mockReturnValue(mockAggregate); + + const response = await getTasksByUserId(mockReq, mockRes); + + assertResMock(400, mockError, response, mockRes); + expect(taskAggregate).toHaveBeenCalled(); + }); + }); + + describe('sendReviewReq function()', () => { + test('Returns 200 on success', async () => { + const { sendReviewReq } = makeSut(); + + mockReq.body = { + ...mockReq.body, + myUserId: 'id', + name: 'name', + taskName: 'task', + }; + + const userProfileFindByIdSpy = jest.spyOn(UserProfile, 'findById').mockResolvedValueOnce([]); + const userProfileFindSpy = jest.spyOn(UserProfile, 'find').mockResolvedValueOnce([]); + + const response = await sendReviewReq(mockReq, mockRes); + + assertResMock(200, 'Success', response, mockRes); + expect(emailSender).toHaveBeenCalledWith( + [], + expect.any(String), + expect.any(String), + null, + null, + ); + expect(userProfileFindByIdSpy).toHaveBeenCalled(); + expect(userProfileFindSpy).toHaveBeenCalled(); + }); + + test('Returns 400 on error', async () => { + const { sendReviewReq } = makeSut(); + + mockReq.body = { + ...mockReq.body, + myUserId: 'id', + name: 'name', + taskName: 'task', + }; + + const mockError = new Error('some error'); + + emailSender.mockImplementation(() => mockError); + + const userProfileFindByIdSpy = jest.spyOn(UserProfile, 'findById').mockResolvedValueOnce([]); + const userProfileFindSpy = jest.spyOn(UserProfile, 'find').mockResolvedValueOnce([]); + + const response = await sendReviewReq(mockReq, mockRes); + + assertResMock(400, mockError, response, mockRes); + + expect(emailSender).toHaveBeenCalledWith( + [], + expect.any(String), + expect.any(String), + null, + null, + ); + expect(userProfileFindByIdSpy).toHaveBeenCalled(); + expect(userProfileFindSpy).toHaveBeenCalled(); + }); + }); + + describe('getTasksForTeamsByUser function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Returns 200 on success - getTasksForTeams', async () => { + mockReq.params.userId = 1234; + const mockData = ['mockData']; + + taskHelperMethods.getTasksForTeams.mockResolvedValueOnce(mockData); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + }); + + test('Returns 200 on success - getTasksForTeamsByUser', async () => { + mockReq.params.userId = 1234; + const mockData = ['mockData']; + + const execMock = { + exec: jest.fn().mockResolvedValueOnce(mockData), + }; + + taskHelperMethods.getTasksForTeams.mockResolvedValueOnce([]); + taskHelperMethods.getTasksForSingleUser.mockImplementation(() => execMock); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(200, mockData, response, mockRes); + }); + + test('Returns 400 on error', async () => { + mockReq.params.userId = 1234; + const mockError = new Error('error'); + + taskHelperMethods.getTasksForTeams.mockRejectedValueOnce(mockError); + + const { getTasksForTeamsByUser } = makeSut(); + + const response = await getTasksForTeamsByUser(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, { error: mockError }, response, mockRes); + }); + }); + + describe('updateTaskStatus function()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockedTask = { + wbs: 111, + }; + const mockedWBS = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + const mockedProject = { + projectId: 111, + modifiedDatetime: new Date(), + save: jest.fn(), + }; + + test('Returns 200 on success - updateTaskStatus', async () => { + const { updateTaskStatus } = makeSut(); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockResolvedValueOnce(true); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTaskStatus(mockReq, mockRes); + await flushPromises(); + + // assertResMock(201, null, response, mockRes); + expect(mockRes.status).toBeCalledWith(201); + expect(response).toBeUndefined(); + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + + test('Returns 400 on error', async () => { + const { updateTaskStatus } = makeSut(); + const error = new Error('some error'); + + mockReq.params = { + ...mockReq.params, + taskId: 456, + }; + + const taskFindByIdSpy = jest.spyOn(Task, 'findById').mockResolvedValue(mockedTask); + const taskFindOneAndUpdateSpy = jest + .spyOn(Task, 'findOneAndUpdate') + .mockRejectedValueOnce(error); + const wbsFindByIdSpy = jest.spyOn(WBS, 'findById').mockResolvedValue(mockedWBS); + const projectFindByIdSpy = jest.spyOn(Project, 'findById').mockResolvedValue(mockedProject); + + const response = await updateTaskStatus(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + + expect(taskFindByIdSpy).toHaveBeenCalled(); + expect(taskFindOneAndUpdateSpy).toHaveBeenCalled(); + expect(wbsFindByIdSpy).toHaveBeenCalled(); + expect(projectFindByIdSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/controllers/teamController.js b/src/controllers/teamController.js index 41f515e99..42d9d8d25 100644 --- a/src/controllers/teamController.js +++ b/src/controllers/teamController.js @@ -6,8 +6,66 @@ const Logger = require('../startup/logger'); const teamcontroller = function (Team) { const getAllTeams = function (req, res) { - Team.find({}) - .sort({ teamName: 1 }) + Team.aggregate([ + { + $unwind: '$members', + }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { + $unwind: '$userProfile', + }, + { + $match: { + isActive: true, + } + }, + { + $group: { + _id: { + teamId: '$_id', + teamCode: '$userProfile.teamCode', + }, + count: { $sum: 1 }, + teamName: { $first: '$teamName' }, + members: { + $push: { + _id: '$userProfile._id', + name: '$userProfile.name', + email: '$userProfile.email', + teamCode: '$userProfile.teamCode', + addDateTime: '$members.addDateTime', + }, + }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { count: -1 }, // Sort by the most frequent teamCode + }, + { + $group: { + _id: '$_id.teamId', + teamCode: { $first: '$_id.teamCode' }, // Get the most frequent teamCode + teamName: { $first: '$teamName' }, + members: { $first: '$members' }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { teamName: 1 }, // Sort teams by name + }, + ]) .then((results) => res.status(200).send(results)) .catch((error) => { Logger.logException(error); @@ -110,14 +168,15 @@ const teamcontroller = function (Team) { return; } - const canEditTeamCode = - req.body.requestor.role === 'Owner' || - req.body.requestor.permissions?.frontPermissions.includes('editTeamCode'); + // Removed the permission check as the permission check if done in earlier + // const canEditTeamCode = + // req.body.requestor.role === 'Owner' || + // req.body.requestor.permissions?.frontPermissions.includes('editTeamCode'); - if (!canEditTeamCode) { - res.status(403).send('You are not authorized to edit team code.'); - return; - } + // if (!canEditTeamCode) { + // res.status(403).send('You are not authorized to edit team code.'); + // return; + // } record.teamName = req.body.teamName; record.isActive = req.body.isActive; @@ -222,59 +281,62 @@ const teamcontroller = function (Team) { }, }, ]) - .then((result) => res.status(200).send(result)) + .then((result) => { + res.status(200).send(result) + }) .catch((error) => { Logger.logException(error, null, `TeamId: ${teamId} Request:${req.body}`); - res.status(500).send(error); + return res.status(500).send(error); }); }; const updateTeamVisibility = async (req, res) => { - console.log("==============> 9 "); + console.log('==============> 9 '); const { visibility, teamId, userId } = req.body; - + try { Team.findById(teamId, (error, team) => { if (error || team === null) { res.status(400).send('No valid records found'); return; } - - const memberIndex = team.members.findIndex(member => member.userId.toString() === userId); + + const memberIndex = team.members.findIndex((member) => member.userId.toString() === userId); if (memberIndex === -1) { res.status(400).send('Member not found in the team.'); return; } - + team.members[memberIndex].visible = visibility; team.modifiedDatetime = Date.now(); - - team.save() - .then(updatedTeam => { - // Additional operations after team.save() + + team + .save() + .then((updatedTeam) => { + // Additional operations after team.save() const assignlist = []; const unassignlist = []; - team.members.forEach(member => { + team.members.forEach((member) => { if (member.userId.toString() === userId) { // Current user, no need to process further return; } - + if (visibility) { assignlist.push(member.userId); } else { - console.log("Visiblity set to false so removing it"); + console.log('Visiblity set to false so removing it'); unassignlist.push(member.userId); } }); - + const addTeamToUserProfile = userProfile .updateMany({ _id: { $in: assignlist } }, { $addToSet: { teams: teamId } }) .exec(); const removeTeamFromUserProfile = userProfile .updateMany({ _id: { $in: unassignlist } }, { $pull: { teams: teamId } }) .exec(); - + Promise.all([addTeamToUserProfile, removeTeamFromUserProfile]) .then(() => { res.status(200).send({ result: 'Done' }); @@ -283,18 +345,17 @@ const teamcontroller = function (Team) { res.status(500).send({ error }); }); }) - .catch(errors => { + .catch((errors) => { console.error('Error saving team:', errors); res.status(400).send(errors); }); - }); } catch (error) { - res.status(500).send(`Error updating team visibility: ${ error.message}`); + res.status(500).send(`Error updating team visibility: ${error.message}`); } }; - /** + /** * Leaner version of the teamcontroller.getAllTeams * Remove redundant data: members, isActive, createdDatetime, modifiedDatetime. */ @@ -308,7 +369,48 @@ const teamcontroller = function (Team) { res.status(500).send('Fetch team code failed.'); }); }; - + + const getAllTeamMembers = async function (req,res) { + try{ + const teamIds = req.body; + const cacheKey='teamMembersCache' + if(cache.hasCache(cacheKey)){ + let data=cache.getCache('teamMembersCache') + return res.status(200).send(data); + } + if (!Array.isArray(teamIds) || teamIds.length === 0 || !teamIds.every(team => mongoose.Types.ObjectId.isValid(team._id))) { + return res.status(400).send({ error: 'Invalid request: teamIds must be a non-empty array of valid ObjectId strings.' }); + } + let data = await Team.aggregate([ + { + $match: { _id: { $in: teamIds.map(team => mongoose.Types.ObjectId(team._id)) } } + }, + { $unwind: '$members' }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { $unwind: { path: '$userProfile', preserveNullAndEmptyArrays: true } }, + { + $group: { + _id: '$_id', // Group by team ID + teamName: { $first: '$teamName' }, // Use $first to keep the team name + createdDatetime: { $first: '$createdDatetime' }, + members: { $push: '$members' }, // Rebuild the members array + }, + }, + ]) + cache.setCache(cacheKey,data) + res.status(200).send(data); + }catch(error){ + console.log(error) + res.status(500).send({'message':"Fetching team members failed"}); + } + } return { getAllTeams, getAllTeamCode, @@ -319,6 +421,7 @@ const teamcontroller = function (Team) { assignTeamToUsers, getTeamMembership, updateTeamVisibility, + getAllTeamMembers }; }; diff --git a/src/controllers/timeEntryController.js b/src/controllers/timeEntryController.js index 6e8d36596..44a50fcfb 100644 --- a/src/controllers/timeEntryController.js +++ b/src/controllers/timeEntryController.js @@ -1,5 +1,6 @@ const moment = require('moment-timezone'); const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); const logger = require('../startup/logger'); const UserProfile = require('../models/userProfile'); const Project = require('../models/project'); @@ -419,7 +420,7 @@ const addEditHistory = async (One Community
-ADMINISTRATIVE DETAILS:
Start Date: ${moment(userprofile.startDate).utc().format('M-D-YYYY')}
Role: ${userprofile.role}
@@ -593,7 +594,7 @@ const timeEntrycontroller = function (TimeEntry) { await timeEntry.save({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); } @@ -866,7 +867,7 @@ const timeEntrycontroller = function (TimeEntry) { } await timeEntry.save({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); @@ -939,7 +940,7 @@ const timeEntrycontroller = function (TimeEntry) { await timeEntry.remove({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); @@ -1062,40 +1063,115 @@ const timeEntrycontroller = function (TimeEntry) { }); }; - const getTimeEntriesForReports = function (req, res) { + const getTimeEntriesForReports =async function (req, res) { const { users, fromDate, toDate } = req.body; + const cacheKey = `timeEntry_${fromDate}_${toDate}`; + const timeentryCache=cacheClosure(); + const cacheData=timeentryCache.hasCache(cacheKey) + if(cacheData){ + const data = timeentryCache.getCache(cacheKey); + return res.status(200).send(data); + } + try { + const results = await TimeEntry.find( + { + personId: { $in: users }, + dateOfWork: { $gte: fromDate, $lte: toDate }, + }, + '-createdDateTime' // Exclude unnecessary fields + ) + .lean() // Returns plain JavaScript objects, not Mongoose documents + .populate({ + path: 'projectId', + select: '_id projectName', // Only return necessary fields from the project + }) + .exec(); // Executes the query + const data = results.map(element => { + const record = { + _id: element._id, + isTangible: element.isTangible, + personId: element.personId, + dateOfWork: element.dateOfWork, + hours: formatSeconds(element.totalSeconds)[0], + minutes: formatSeconds(element.totalSeconds)[1], + projectId: element.projectId?._id || '', + projectName: element.projectId?.projectName || '', + }; + return record; + }); + timeentryCache.setCache(cacheKey,data); + return res.status(200).send(data); + } catch (error) { + res.status(400).send(error); + } + }; + const getTimeEntriesForProjectReports = function (req, res) { + const { users, fromDate, toDate } = req.body; + + // Fetch only necessary fields and avoid bringing the entire document TimeEntry.find( { personId: { $in: users }, dateOfWork: { $gte: fromDate, $lte: toDate }, }, - ' -createdDateTime', + 'totalSeconds isTangible dateOfWork projectId', ) - .populate('projectId') - + .populate('projectId', 'projectName _id') + .lean() // lean() for better performance as we don't need Mongoose document methods .then((results) => { - const data = []; - - results.forEach((element) => { - const record = {}; - record._id = element._id; - record.isTangible = element.isTangible; - record.personId = element.personId._id; - record.dateOfWork = element.dateOfWork; + const data = results.map((element) => { + const record = { + isTangible: element.isTangible, + dateOfWork: element.dateOfWork, + projectId: element.projectId ? element.projectId._id : '', + projectName: element.projectId ? element.projectId.projectName : '', + }; + + // Convert totalSeconds to hours and minutes [record.hours, record.minutes] = formatSeconds(element.totalSeconds); - record.projectId = element.projectId ? element.projectId._id : ''; - record.projectName = element.projectId ? element.projectId.projectName : ''; - data.push(record); + + return record; }); res.status(200).send(data); }) .catch((error) => { - res.status(400).send(error); + res.status(400).send({ message: 'Error fetching time entries for project reports', error }); }); }; + const getTimeEntriesForPeopleReports = async function (req, res) { + try { + const { users, fromDate, toDate } = req.body; + + const results = await TimeEntry.find( + { + personId: { $in: users }, + dateOfWork: { $gte: fromDate, $lte: toDate }, + }, + 'personId totalSeconds isTangible dateOfWork', + ).lean(); // Use lean() for better performance + + const data = results + .map((entry) => { + const [hours, minutes] = formatSeconds(entry.totalSeconds); + return { + personId: entry.personId, + hours, + minutes, + isTangible: entry.isTangible, + dateOfWork: entry.dateOfWork, + }; + }) + .filter(Boolean); + + res.status(200).send(data); + } catch (error) { + res.status(400).send({ message: 'Error fetching time entries for people reports', error }); + } + }; + /** * Get time entries for a specified project */ @@ -1208,7 +1284,12 @@ const timeEntrycontroller = function (TimeEntry) { */ const getLostTimeEntriesForTeamList = function (req, res) { const { teams, fromDate, toDate } = req.body; - + const lostteamentryCache=cacheClosure() + const cacheKey = `LostTeamEntry_${fromDate}_${toDate}`; + const cacheData=lostteamentryCache.getCache(cacheKey) + if(cacheData){ + return res.status(200).send(cacheData) + } TimeEntry.find( { entryType: 'team', @@ -1217,7 +1298,7 @@ const timeEntrycontroller = function (TimeEntry) { isActive: { $ne: false }, }, ' -createdDateTime', - ) + ).lean() .populate('teamId') .sort({ lastModifiedDateTime: -1 }) .then((results) => { @@ -1234,7 +1315,8 @@ const timeEntrycontroller = function (TimeEntry) { [record.hours, record.minutes] = formatSeconds(element.totalSeconds); data.push(record); }); - res.status(200).send(data); + lostteamentryCache.setCache(cacheKey,data); + return res.status(200).send(data); }) .catch((error) => { res.status(400).send(error); @@ -1367,10 +1449,12 @@ const timeEntrycontroller = function (TimeEntry) { return newTotalIntangibleHrs; }; + const recalculationTaskQueue = []; + /** - * recalculate the hoursByCatefory for all users and update the field + * recalculate the hoursByCategory for all users and update the field */ - const recalculateHoursByCategoryAllUsers = async function (req, res) { + const recalculateHoursByCategoryAllUsers = async function (taskId) { const session = await mongoose.startSession(); session.startTransaction(); @@ -1385,18 +1469,60 @@ const timeEntrycontroller = function (TimeEntry) { await Promise.all(recalculationPromises); await session.commitTransaction(); - return res.status(200).send({ - message: 'finished the recalculation for hoursByCategory for all users', - }); + + const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); + if (recalculationTask) { + recalculationTask.status = 'Completed'; + recalculationTask.completionTime = new Date().toISOString(); + } } catch (err) { await session.abortTransaction(); + const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); + if (recalculationTask) { + recalculationTask.status = 'Failed'; + recalculationTask.completionTime = new Date().toISOString(); + } + logger.logException(err); - return res.status(500).send({ error: err.toString() }); } finally { session.endSession(); } }; + const startRecalculation = async function (req, res) { + const taskId = uuidv4(); + recalculationTaskQueue.push({ + taskId, + status: 'In progress', + startTime: new Date().toISOString(), + completionTime: null, + }); + if (recalculationTaskQueue.length > 10) { + recalculationTaskQueue.shift(); + } + + res.status(200).send({ + message: 'The recalculation task started in the background', + taskId, + }); + + setTimeout(() => recalculateHoursByCategoryAllUsers(taskId), 0); + }; + + const checkRecalculationStatus = async function (req, res) { + const { taskId } = req.params; + const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); + if (recalculationTask) { + res.status(200).send({ + status: recalculationTask.status, + startTime: recalculationTask.startTime, + completionTime: recalculationTask.completionTime, + }); + } else { + res.status(404).send({ message: 'Task not found' }); + } + }; + /** * recalculate the totalIntangibleHrs for all users and update the field */ @@ -1441,9 +1567,12 @@ const timeEntrycontroller = function (TimeEntry) { getLostTimeEntriesForTeamList, backupHoursByCategoryAllUsers, backupIntangibleHrsAllUsers, - recalculateHoursByCategoryAllUsers, recalculateIntangibleHrsAllUsers, getTimeEntriesForReports, + getTimeEntriesForProjectReports, + getTimeEntriesForPeopleReports, + startRecalculation, + checkRecalculationStatus, }; }; diff --git a/src/controllers/timeZoneAPIController.js b/src/controllers/timeZoneAPIController.js index 07c9c0b17..2023f312a 100644 --- a/src/controllers/timeZoneAPIController.js +++ b/src/controllers/timeZoneAPIController.js @@ -1,11 +1,11 @@ // eslint-disable-next-line import/no-extraneous-dependencies const fetch = require('node-fetch'); +const dotenv = require('dotenv'); + +dotenv.config(); const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); const { hasPermission } = require('../utilities/permissions'); -const premiumKey = process.env.TIMEZONE_PREMIUM_KEY; -const commonKey = process.env.TIMEZONE_COMMON_KEY; - const performTimeZoneRequest = async (req, res, apiKey) => { const { location } = req.params; @@ -17,7 +17,6 @@ const performTimeZoneRequest = async (req, res, apiKey) => { try { const geocodeAPIEndpoint = 'https://api.opencagedata.com/geocode/v1/json'; const url = `${geocodeAPIEndpoint}?key=${apiKey}&q=${location}&pretty=1&limit=1`; - const response = await fetch(url); const data = await response.json(); @@ -53,16 +52,17 @@ const performTimeZoneRequest = async (req, res, apiKey) => { const timeZoneAPIController = function () { const getTimeZone = async (req, res) => { + const premiumKey = process.env.TIMEZONE_PREMIUM_KEY; + const commonKey = process.env.TIMEZONE_COMMON_KEY; const { requestor } = req.body; - if (!requestor.role) { res.status(403).send('Unauthorized Request'); return; } - const userAPIKey = (await hasPermission(requestor, 'getTimeZoneAPIKey')) ? premiumKey : commonKey; + if (!userAPIKey) { res.status(401).send('API Key Missing'); return; @@ -72,6 +72,7 @@ const timeZoneAPIController = function () { }; const getTimeZoneProfileInitialSetup = async (req, res) => { + const commonKey = process.env.TIMEZONE_COMMON_KEY; const { token } = req.body; if (!token) { res.status(400).send('Missing token'); diff --git a/src/controllers/timeZoneAPIController.spec.js b/src/controllers/timeZoneAPIController.spec.js new file mode 100644 index 000000000..2f3607cc6 --- /dev/null +++ b/src/controllers/timeZoneAPIController.spec.js @@ -0,0 +1,316 @@ +jest.mock('../utilities/permissions', () => ({ + hasPermission: jest.fn(), // Mocking the hasPermission function +})); + +jest.mock('node-fetch'); +// eslint-disable-next-line import/no-extraneous-dependencies +const fetch = require('node-fetch'); + +const originalPremiumKey = process.env.TIMEZONE_PREMIUM_KEY; +process.env.TIMEZONE_PREMIUM_KEY = 'mockPremiumKey'; + +const originalCommonKey = process.env.TIMEZONE_COMMON_KEY; +delete process.env.TIMEZONE_COMMON_KEY; + +const successfulFetchRequestWithResults = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: 200, + message: 'Request Processed Successfully', + }, + results: [ + { + annotations: { + timezone: { + name: 'timeZone - Fiji', + }, + }, + geometry: { + lat: 1, + lng: 1, + }, + components: { + country: 'U.S.', + city: 'Paris', + }, + }, + ], + }), + }), +); + +const successfulFetchRequestWithNoResults = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: 200, + message: 'Request Processed Successfully', + }, + results: [], + }), + }), +); + +const unsuccessfulFetchRequestInternalServerError = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + status: { + code: null, + message: 'Internal Server Error', + }, + results: [], + }), + }), +); + +const { hasPermission } = require('../utilities/permissions'); +const timeZoneAPIController = require('./timeZoneAPIController'); +const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +const flushPromises = () => new Promise(setImmediate); +const makeSut = () => { + const { getTimeZone, getTimeZoneProfileInitialSetup } = timeZoneAPIController(); + return { getTimeZone, getTimeZoneProfileInitialSetup }; +}; + +describe('timeZoneAPIController Unit Tests', () => { + afterAll(() => { + // Reseting TIMEZONE_PREMIUM_KEY and TIMEZONE_COMMON_KEY environment variables to their original values + if (originalPremiumKey) { + process.env.TIMEZONE_PREMIUM_KEY = originalPremiumKey; + } else { + delete process.env.TIMEZONE_PREMIUM_KEY; + } + + if (originalCommonKey) { + process.env.TIMEZONE_COMMON_KEY = originalCommonKey; + } else { + delete process.env.TIMEZONE_COMMON_KEY; + } + }); + + describe('getTimeZone() function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + hasPermission.mockResolvedValue(true); + }); + test('Returns 403, as requestor.role is missing in request body', async () => { + const { getTimeZone } = makeSut(); + + // setting request.role to `Null` + mockReq.body.requestor.role = null; + + const response = await getTimeZone(mockReq, mockRes); + + assertResMock(403, 'Unauthorized Request', response, mockRes); + }); + + test('Returns 401, as API is missing', async () => { + delete process.env.TIMEZONE_COMMON_KEY; + + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + + hasPermission.mockResolvedValue(false); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(hasPermission).toBeCalledTimes(1); + assertResMock(401, 'API Key Missing', response, mockRes); + }); + + test('Returns 400, when `location` is missing in req.params', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(hasPermission).toBeCalledTimes(1); + assertResMock(400, 'Missing location', response, mockRes); + }); + + test('Returns 500, when status.code !== 200 and status code is missing', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(unsuccessfulFetchRequestInternalServerError); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(500, 'opencage error- Internal Server Error', response, mockRes); + }); + + test('Returns 404, when status.code == 200 and data.results is empty', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(successfulFetchRequestWithNoResults); + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(404, 'No results found', response, mockRes); + }); + + test('Returns 200, when status.code == 200 and data.results is not empty', async () => { + const { getTimeZone } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + fetch.mockImplementation(successfulFetchRequestWithResults); + const timezone = 'timeZone - Fiji'; // mocking the timezone data to be returned by `successfulFetchRequestWithResults` + const currentLocation = { + // mocking the currentLocation data to be returned by `successfulFetchRequestWithResults` + userProvided: mockReq.params.location, + coords: { + lat: 1, + lng: 1, + }, + country: 'U.S.', + city: 'Paris', + }; + + const response = await getTimeZone(mockReq, mockRes); + await flushPromises(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(hasPermission).toBeCalledTimes(1); + assertResMock(200, { timezone, currentLocation }, response, mockRes); + }); + }); + + describe('getTimeZoneProfileInitialSetup() function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + hasPermission.mockResolvedValue(true); + }); + + test('Returns status code 400 if token is missing in request.body', async () => { + mockReq.body.token = null; + + const { getTimeZoneProfileInitialSetup } = makeSut(); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + assertResMock(400, 'Missing token', response, mockRes); + }); + + test('Returns status code 403 if token is missing in request.body', async () => { + mockReq.body.token = 'random_token_value'; + + const { getTimeZoneProfileInitialSetup } = makeSut(); + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue(null); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + assertResMock(403, 'Unauthorized Request', response, mockRes); + }); + + test('Returns 500, when status.code !== 200 and status code is missing', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(unsuccessfulFetchRequestInternalServerError); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(500, 'opencage error- Internal Server Error', response, mockRes); + }); + + test('Returns 404, when status.code == 200 and data.results is empty', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(successfulFetchRequestWithNoResults); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(404, 'No results found', response, mockRes); + }); + + test('Returns 200, when status.code == 200 and data.results is not empty', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = 'New Jersey'; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + fetch.mockImplementation(successfulFetchRequestWithResults); + + const timezone = 'timeZone - Fiji'; // mocking the timezone data to be returned by `successfulFetchRequestWithResults` + const currentLocation = { + // mocking the currentLocation data to be returned by `successfulFetchRequestWithResults` + userProvided: mockReq.params.location, + coords: { + lat: 1, + lng: 1, + }, + country: 'U.S.', + city: 'Paris', + }; + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + assertResMock(200, { timezone, currentLocation }, response, mockRes); + }); + + test('Returns 400, when `location` is missing in req.params', async () => { + const { getTimeZoneProfileInitialSetup } = makeSut(); + mockReq.body.requestor.role = 'Volunteer'; + mockReq.params.location = null; + + const profileInitialSetupTokenFindOneSpy = jest + .spyOn(ProfileInitialSetupToken, 'findOne') + .mockReturnValue('token'); + + const response = await getTimeZoneProfileInitialSetup(mockReq, mockRes); + await flushPromises(); + + expect(profileInitialSetupTokenFindOneSpy).toBeCalledTimes(1); + assertResMock(400, 'Missing location', response, mockRes); + }); + }); +}); diff --git a/src/controllers/titleController.js b/src/controllers/titleController.js index 08751bdef..277842c43 100644 --- a/src/controllers/titleController.js +++ b/src/controllers/titleController.js @@ -1,130 +1,206 @@ -const Team = require('../models/team'); const Project = require('../models/project'); const cacheClosure = require('../utilities/nodeCache'); -const { getAllTeamCodeHelper } = require("./userProfileController"); +const userProfileController = require("./userProfileController"); +const userProfile = require('../models/userProfile'); +const project = require('../models/project'); + +const controller = userProfileController(userProfile, project); +const { getAllTeamCodeHelper } = controller; const titlecontroller = function (Title) { const cache = cacheClosure(); - const getAllTitles = function (req, res) { - Title.find({}) - .then((results) => res.status(200).send(results)) - .catch((error) => res.status(404).send(error)); + // Update: Confirmed with Jae. Team code is not related to the Team data model. But the team code field within the UserProfile data model. + async function checkTeamCodeExists(teamCode) { + try { + if (cache.getCache('teamCodes')) { + const teamCodes = JSON.parse(cache.getCache('teamCodes')); + return teamCodes.includes(teamCode); + } + const teamCodes = await getAllTeamCodeHelper(); + return teamCodes.includes(teamCode); + } catch (error) { + console.error('Error checking if team code exists:', error); + throw error; + } + } + + async function checkProjectExists(projectID) { + try { + const proj = await Project.findOne({ _id: projectID }).exec(); + return !!proj; + } catch (error) { + console.error('Error checking if project exists:', error); + throw error; + } + } + + const getAllTitles = function (req, res) { + Title.find({}) + .then((results) => res.status(200).send(results)) + .catch((error) => res.status(404).send(error)); }; - const getTitleById = function (req, res) { - const { titleId } = req.params; + const getTitleById = function (req, res) { + const { titleId } = req.params; - Title.findById(titleId) - .then((results) => res.send(results)) - .catch((error) => res.send(error)); - }; + Title.findById(titleId) + .then((results) => res.send(results)) + .catch((error) => res.send(error)); + }; + + const postTitle = async function (req, res) { + const title = new Title(); + title.titleName = req.body.titleName; + title.titleCode = req.body.titleCode; + title.teamCode = req.body.teamCode; + title.projectAssigned = req.body.projectAssigned; + title.mediaFolder = req.body.mediaFolder; + title.teamAssiged = req.body.teamAssiged; + + const titleCodeRegex = /^[A-Za-z]+$/; + if (!title.titleCode || !title.titleCode.trim()) { + return res.status(400).send({ message: 'Title code cannot be empty.' }); + } - const postTitle = async function (req, res) { - const title = new Title(); + if (!titleCodeRegex.test(title.titleCode)) { + return res.status(400).send({ message: 'Title Code must contain only upper or lower case letters.' }); + } + + // valid title name + if (!title.titleName.trim()) { + res.status(400).send({ message: 'Title cannot be empty.' }); + return; + } - title.titleName = req.body.titleName; - title.teamCode = req.body.teamCode; - title.projectAssigned = req.body.projectAssigned; - title.mediaFolder = req.body.mediaFolder; - title.teamAssiged = req.body.teamAssiged; + // if media is empty + if (!title.mediaFolder.trim()) { + res.status(400).send({ message: 'Media folder cannot be empty.' }); + return; + } + + if (!title.teamCode) { + res.status(400).send({ message: 'Please provide a team code.' }); + return; + } + + const teamCodeExists = await checkTeamCodeExists(title.teamCode); + if (!teamCodeExists) { + res.status(400).send({ message: 'Invalid team code. Please provide a valid team code.' }); + return; + } + + // validate if project exist + const projectExist = await checkProjectExists(title.projectAssigned._id); + if (!projectExist) { + res.status(400).send({ message: 'Project lalala is empty or not exist!!!' }); + return; + } + + // validate if team exist + if (title.teamAssiged && title.teamAssiged._id === 'N/A') { + res.status(400).send({ message: 'Team not exists.' }); + return; + } + + title + .save() + .then((results) => res.status(200).send(results)) + .catch((error) => res.status(404).send(error)) + }; + + // update title function. + const updateTitle = async function (req, res) { + try { + + const filter = req.body.id; // valid title name - if (!title.titleName.trim()) { + if (!req.body.titleName.trim()) { res.status(400).send({ message: 'Title cannot be empty.' }); return; } - // if media is empty - if (!title.mediaFolder.trim()) { - res.status(400).send({ message: 'Media folder cannot be empty.' }); + if (!req.body.titleCode.trim()) { + res.status(400).send({ message: 'Title code cannot be empty.' }); return; } - const shortnames = title.titleName.trim().split(' '); - let shortname; - if (shortnames.length > 1) { - shortname = (shortnames[0][0] + shortnames[1][0]).toUpperCase(); - } else if (shortnames.length === 1) { - shortname = shortnames[0][0].toUpperCase(); + const titleCodeRegex = /^[A-Za-z]+$/; + if (!titleCodeRegex.test(req.body.titleCode)) { + return res.status(400).send({ message: 'Title Code must contain only upper or lower case letters.' }); } - title.shortName = shortname; - // Validate team code by checking if it exists in the database - if (!title.teamCode) { + // if media is empty + if (!req.body.mediaFolder.trim()) { + res.status(400).send({ message: 'Media folder cannot be empty.' }); + return; + } + + if (!req.body.teamCode) { res.status(400).send({ message: 'Please provide a team code.' }); return; } - const teamCodeExists = await checkTeamCodeExists(title.teamCode); + const teamCodeExists = await checkTeamCodeExists(req.body.teamCode); if (!teamCodeExists) { res.status(400).send({ message: 'Invalid team code. Please provide a valid team code.' }); return; } // validate if project exist - const projectExist = await checkProjectExists(title.projectAssigned._id); + const projectExist = await checkProjectExists(req.body.projectAssigned._id); if (!projectExist) { - res.status(400).send({ message: 'Project is empty or not exist.' }); + res.status(400).send({ message: 'Project is empty or not exist~~~' }); return; } // validate if team exist - if (title.teamAssiged && title.teamAssiged._id === 'N/A') { + if (req.body.teamAssiged && req.body.teamAssiged._id === 'N/A') { res.status(400).send({ message: 'Team not exists.' }); return; } + const result = await Title.findById(filter); + result.titleName = req.body.titleName; + result.titleCode = req.body.titleCode; + result.teamCode = req.body.teamCode; + result.projectAssigned = req.body.projectAssigned; + result.mediaFolder = req.body.mediaFolder; + result.teamAssiged = req.body.teamAssiged; + const updatedTitle = await result.save(); + res.status(200).send({ message: 'Update successful', updatedTitle }); + + } catch (error) { + console.log(error); + res.status(500).send({ message: 'An error occurred', error }); + } + + }; + + const deleteTitleById = async function (req, res) { + const { titleId } = req.params; + Title.deleteOne({ _id: titleId }) + .then((result) => res.send(result)) + .catch((error) => res.send(error)); + }; - title - .save() - .then((results) => res.status(200).send(results)) - .catch((error) => res.status(404).send(error)); - }; - - const deleteTitleById = async function (req, res) { - const { titleId } = req.params; - Title.deleteOne({ _id: titleId }) - .then((result) => res.send(result)) - .catch((error) => res.send(error)); - }; - - const deleteAllTitles = async function (req, res) { - Title.deleteMany({}) - .then((result) => { - if (result.deletedCount === 0) { - res.send({ message: 'No titles found to delete.' }); - } else { - res.send({ message: `${result.deletedCount} titles were deleted successfully.` }); - } - }) - .catch((error) => { - res.status(500).send(error); - }); - }; - // Update: Confirmed with Jae. Team code is not related to the Team data model. But the team code field within the UserProfile data model. - async function checkTeamCodeExists(teamCode) { - try { - if (cache.getCache('teamCodes')) { - const teamCodes = JSON.parse(cache.getCache('teamCodes')); - return teamCodes.includes(teamCode); + const deleteAllTitles = async function (req, res) { + Title.deleteMany({}) + .then((result) => { + if (result.deletedCount === 0) { + res.send({ message: 'No titles found to delete.' }); + } else { + res.send({ message: `${result.deletedCount} titles were deleted successfully.` }); } - const teamCodes = await getAllTeamCodeHelper(); - return teamCodes.includes(teamCode); - } catch (error) { - console.error('Error checking if team code exists:', error); - throw error; - } - } + }) + .catch((error) => { + console.log(error) + res.status(500).send(error); + }); + }; + + - async function checkProjectExists(projectID) { - try { - const project = await Project.findOne({ _id: projectID }).exec(); - return !!project; - } catch (error) { - console.error('Error checking if project exists:', error); - throw error; - } - } return { getAllTitles, @@ -132,8 +208,8 @@ const titlecontroller = function (Title) { postTitle, deleteTitleById, deleteAllTitles, + updateTitle }; }; - module.exports = titlecontroller; - \ No newline at end of file +module.exports = titlecontroller; diff --git a/src/controllers/userProfileController.js b/src/controllers/userProfileController.js index 8226aee65..6a23dd34b 100644 --- a/src/controllers/userProfileController.js +++ b/src/controllers/userProfileController.js @@ -196,6 +196,38 @@ const userProfileController = function (UserProfile, Project) { .catch((error) => res.status(404).send(error)); }; + /** + * Controller function to retrieve basic user profile information. + * This endpoint checks if the user has the necessary permissions to access user profiles. + * If authorized, it queries the database to fetch only the required fields: + * _id, firstName, lastName, isActive, startDate, and endDate, sorted by last name. + */ + const getUserProfileBasicInfo = async function (req, res) { + if (!(await checkPermission(req, 'getUserProfiles'))) { + forbidden(res, 'You are not authorized to view all users'); + return; + } + + await UserProfile.find({}, '_id firstName lastName isActive startDate createdDate endDate') + .sort({ + lastName: 1, + }) + .then((results) => { + if (!results) { + if (cache.getCache('allusers')) { + const getData = JSON.parse(cache.getCache('allusers')); + res.status(200).send(getData); + return; + } + res.status(500).send({ error: 'User result was invalid' }); + return; + } + cache.setCache('allusers', JSON.stringify(results)); + res.status(200).send(results); + }) + .catch((error) => res.status(404).send(error)); + }; + const getProjectMembers = async function (req, res) { if (!(await hasPermission(req.body.requestor, 'getProjectMembers'))) { res.status(403).send('You are not authorized to view all users'); @@ -326,6 +358,7 @@ const userProfileController = function (UserProfile, Project) { up.adminLinks = req.body.adminLinks; up.teams = Array.from(new Set(req.body.teams)); up.projects = Array.from(new Set(req.body.projects)); + up.teamCode = req.body.teamCode; up.createdDate = req.body.createdDate; up.startDate = req.body.startDate ? req.body.startDate : req.body.createdDate; up.email = req.body.email; @@ -505,7 +538,7 @@ const userProfileController = function (UserProfile, Project) { } }); - // Since we leverage cache for all team code retrival (refer func getAllTeamCode()), + // Since we leverage cache for all team code retrival (refer func getAllTeamCode()), // we need to remove the cache when team code is updated in case of new team code generation if (req.body.teamCode) { // remove teamCode cache when new team assigned @@ -644,7 +677,7 @@ const userProfileController = function (UserProfile, Project) { } if (req.body.startDate !== undefined && record.startDate !== req.body.startDate) { - record.startDate = moment(req.body.startDate).toDate(); + record.startDate = moment.tz(req.body.startDate, 'America/Los_Angeles').toDate(); // Make sure weeklycommittedHoursHistory isn't empty if (record.weeklycommittedHoursHistory.length === 0) { const newEntry = { @@ -667,7 +700,7 @@ const userProfileController = function (UserProfile, Project) { if (req.body.endDate !== undefined) { if (yearMonthDayDateValidator(req.body.endDate)) { - record.endDate = moment(req.body.endDate).toDate(); + record.endDate = moment.tz(req.body.endDate, 'America/Los_Angeles').toDate(); if (isUserInCache) { userData.endDate = record.endDate.toISOString(); } @@ -684,12 +717,7 @@ const userProfileController = function (UserProfile, Project) { userData.startDate = record.startDate.toISOString(); } } - if ( - req.body.infringements !== undefined && - (await hasPermission(req.body.requestor, 'infringementAuthorizer')) - ) { - record.infringements = req.body.infringements; - } + let updatedDiff = null; if (PROTECTED_EMAIL_ACCOUNT.includes(record.email)) { updatedDiff = record.modifiedPaths(); @@ -727,7 +755,17 @@ const userProfileController = function (UserProfile, Project) { 'update', ); }) - .catch((error) => res.status(400).send(error)); + .catch((error) => { + if (error.name === 'ValidationError' && error.errors.lastName) { + const errors = Object.values(error.errors).map((er) => er.message); + return res.status(400).json({ + message: 'Validation Error', + error: errors, + }); + } + console.error('Failed to save record:', error); + return res.status(400).json({ error: 'Failed to save record.' }); + }); }); }; @@ -843,11 +881,11 @@ const userProfileController = function (UserProfile, Project) { const getUserById = function (req, res) { const userid = req.params.userId; - if (cache.getCache(`user-${userid}`)) { - const getData = JSON.parse(cache.getCache(`user-${userid}`)); - res.status(200).send(getData); - return; - } + // if (cache.getCache(`user-${userid}`)) { + // const getData = JSON.parse(cache.getCache(`user-${userid}`)); + // res.status(200).send(getData); + // return; + // } UserProfile.findById(userid, '-password -refreshTokens -lastModifiedDate -__v') .populate([ @@ -877,6 +915,15 @@ const userProfileController = function (UserProfile, Project) { select: '_id badgeName type imageUrl description ranking showReport', }, }, + { + path: 'infringements', // Populate infringements field + select: 'date description', + options: { + sort: { + date: -1, // Sort by date descending if needed + }, + }, + }, ]) .exec() .then((results) => { @@ -1021,7 +1068,7 @@ const userProfileController = function (UserProfile, Project) { const hasUpdatePasswordPermission = await hasPermission(requestor, 'updatePassword'); // if they're updating someone else's password, they need the 'updatePassword' permission. - if (!hasUpdatePasswordPermission) { + if (userId !== requestor.requestorId && !hasUpdatePasswordPermission) { return res.status(403).send({ error: "You are unauthorized to update this user's password", }); @@ -1156,7 +1203,18 @@ const userProfileController = function (UserProfile, Project) { const activationDate = req.body.reactivationDate; const { endDate } = req.body; const isSet = req.body.isSet === 'FinalDay'; - + let activeStatus = status; + let emailThreeWeeksSent = false; + if (endDate && status) { + const dateObject = new Date(endDate); + dateObject.setHours(dateObject.getHours() + 7); + const setEndDate = dateObject; + if (moment().isAfter(moment(setEndDate).add(1, 'days'))) { + activeStatus = false; + } else if (moment().isBefore(moment(endDate).subtract(3, 'weeks'))) { + emailThreeWeeksSent = true; + } + } if (!mongoose.Types.ObjectId.isValid(userId)) { res.status(400).send({ error: 'Bad Request', @@ -1202,13 +1260,14 @@ const userProfileController = function (UserProfile, Project) { logger.logException(err, 'Unexpected error in finding menagement team'); } - UserProfile.findById(userId, 'isActive email firstName lastName') + UserProfile.findById(userId, 'isActive email firstName lastName finalEmailThreeWeeksSent') .then((user) => { user.set({ - isActive: status, + isActive: activeStatus, reactivationDate: activationDate, endDate, isSet, + finalEmailThreeWeeksSent: emailThreeWeeksSent, }); user .save() @@ -1232,6 +1291,8 @@ const userProfileController = function (UserProfile, Project) { user.email, recipients, isSet, + activationDate, + emailThreeWeeksSent, ); auditIfProtectedAccountUpdated( req.body.requestor.requestorId, @@ -1432,26 +1493,7 @@ const userProfileController = function (UserProfile, Project) { res.status(200).send({ refreshToken: currentRefreshToken }); }; - // Search for user by first name - // const getUserBySingleName = (req, res) => { - // const pattern = new RegExp(`^${ req.params.singleName}`, 'i'); - - // // Searches for first or last name - // UserProfile.find({ - // $or: [ - // { firstName: { $regex: pattern } }, - // { lastName: { $regex: pattern } }, - // ], - // }) - // .select('firstName lastName') - // .then((users) => { - // if (users.length === 0) { - // return res.status(404).send({ error: 'Users Not Found' }); - // } - // res.status(200).send(users); - // }) - // .catch((error) => res.status(500).send(error)); - // }; + const getUserBySingleName = (req, res) => { const pattern = new RegExp(`^${req.params.singleName}`, 'i'); @@ -1496,13 +1538,7 @@ const userProfileController = function (UserProfile, Project) { .catch((error) => res.status(500).send(error)); }; - // function escapeRegExp(string) { - // return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // } - /** - * Authorizes user to be able to add Weekly Report Recipients - * - */ + const authorizeUser = async (req, res) => { try { let authorizedUser; @@ -1539,6 +1575,155 @@ const userProfileController = function (UserProfile, Project) { } }; + const addInfringements = async function (req, res) { + if (!(await hasPermission(req.body.requestor, 'addInfringements'))) { + res.status(403).send('You are not authorized to add blue square'); + return; + } + const userid = req.params.userId; + + cache.removeCache(`user-${userid}`); + + if (req.body.blueSquare === undefined) { + res.status(400).send('Invalid Data'); + return; + } + + UserProfile.findById(userid, async (err, record) => { + if (err || !record) { + res.status(404).send('No valid records found'); + return; + } + // find userData in cache + const isUserInCache = cache.hasCache('allusers'); + let allUserData; + let userData; + let userIdx; + if (isUserInCache) { + allUserData = JSON.parse(cache.getCache('allusers')); + userIdx = allUserData.findIndex((users) => users._id === userid); + userData = allUserData[userIdx]; + } + + const originalinfringements = record?.infringements ?? []; + record.infringements = originalinfringements.concat(req.body.blueSquare); + + record + .save() + .then((results) => { + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); + res.status(200).json({ + _id: record._id, + }); + + // update alluser cache if we have cache + if (isUserInCache) { + allUserData.splice(userIdx, 1, userData); + cache.setCache('allusers', JSON.stringify(allUserData)); + } + }) + .catch((error) => res.status(400).send(error)); + }); + }; + + const editInfringements = async function (req, res) { + if (!(await hasPermission(req.body.requestor, 'editInfringements'))) { + res.status(403).send('You are not authorized to edit blue square'); + return; + } + const { userId, blueSquareId } = req.params; + const { dateStamp, summary } = req.body; + + UserProfile.findById(userId, async (err, record) => { + if (err || !record) { + res.status(404).send('No valid records found'); + return; + } + + const originalinfringements = record?.infringements ?? []; + + record.infringements = originalinfringements.map((blueSquare) => { + if (blueSquare._id.equals(blueSquareId)) { + blueSquare.date = dateStamp ?? blueSquare.date; + blueSquare.description = summary ?? blueSquare.description; + } + return blueSquare; + }); + + record + .save() + .then((results) => { + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); + res.status(200).json({ + _id: record._id, + }); + }) + .catch((error) => res.status(400).send(error)); + }); + }; + + const deleteInfringements = async function (req, res) { + if (!(await hasPermission(req.body.requestor, 'deleteInfringements'))) { + res.status(403).send('You are not authorized to delete blue square'); + return; + } + const { userId, blueSquareId } = req.params; + // console.log(userId, blueSquareId); + + UserProfile.findById(userId, async (err, record) => { + if (err || !record) { + res.status(404).send('No valid records found'); + return; + } + + const originalinfringements = record?.infringements ?? []; + + record.infringements = originalinfringements.filter( + (infringement) => !infringement._id.equals(blueSquareId), + ); + + record + .save() + .then((results) => { + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); + res.status(200).json({ + _id: record._id, + }); + }) + .catch((error) => res.status(400).send(error)); + }); + }; + const getProjectsByPerson = async function (req, res) { try { const { name } = req.params; @@ -1548,28 +1733,28 @@ const userProfileController = function (UserProfile, Project) { const query = match[1] ? { - $or: [ - { - firstName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, - }, - { - $and: [ - { firstName: { $regex: new RegExp(`${escapeRegExp(firstName)}`, 'i') } }, - { lastName: { $regex: new RegExp(`${escapeRegExp(lastName)}`, 'i') } }, - ], - }, - ], - } + $or: [ + { + firstName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, + }, + { + $and: [ + { firstName: { $regex: new RegExp(`${escapeRegExp(firstName)}`, 'i') } }, + { lastName: { $regex: new RegExp(`${escapeRegExp(lastName)}`, 'i') } }, + ], + }, + ], + } : { - $or: [ - { - firstName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, - }, - { - lastName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, - }, - ], - }; + $or: [ + { + firstName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, + }, + { + lastName: { $regex: new RegExp(`${escapeRegExp(name)}`, 'i') }, + }, + ], + }; const userProfile = await UserProfile.find(query); @@ -1597,21 +1782,74 @@ const userProfileController = function (UserProfile, Project) { return teamCodes; } const distinctTeamCodes = await UserProfile.distinct('teamCode', { - teamCode: { $ne: null } + teamCode: { $ne: null }, }); cache.setCache('teamCodes', JSON.stringify(distinctTeamCodes)); return distinctTeamCodes; } catch (error) { throw new Error('Encountered an error to get all team codes, please try again!'); } - } + }; const getAllTeamCode = async function (req, res) { try { const distinctTeamCodes = await getAllTeamCodeHelper(); return res.status(200).send({ message: 'Found', distinctTeamCodes }); } catch (error) { - return res.status(500).send({ message: 'Encountered an error to get all team codes, please try again!' }); + return res + .status(500) + .send({ message: 'Encountered an error to get all team codes, please try again!' }); + } + }; + + const getUserByAutocomplete = (req, res) => { + const { searchText } = req.params; + + if (!searchText) { + return res.status(400).send({ message: 'Search text is required' }); + } + + const regex = new RegExp(searchText, 'i'); // Case-insensitive regex for partial matching + + UserProfile.find( + { + $or: [ + { firstName: { $regex: regex } }, + { lastName: { $regex: regex } }, + { + $expr: { + $regexMatch: { + input: { $concat: ['$firstName', ' ', '$lastName'] }, + regex: searchText, + options: 'i', + }, + }, + }, + ], + }, + '_id firstName lastName', // Projection to limit fields returned + ) + .limit(10) // Limit results for performance + .then((results) => { + res.status(200).send(results); + }) + .catch(() => { + res.status(500).send({ error: 'Internal Server Error' }); + }); + }; + + const updateUserInformation = async function (req,res){ + try { + const data=req.body; + data.map(async (e)=> { + const result = await UserProfile.findById(e.user_id); + result[e.item]=e.value + await result.save(); + }) + res.status(200).send({ message: 'Update successful'}); + } catch (error) { + console.log(error) + return res.status(500) } } @@ -1637,9 +1875,15 @@ const userProfileController = function (UserProfile, Project) { getUserByFullName, changeUserRehireableStatus, authorizeUser, + addInfringements, + editInfringements, + deleteInfringements, getProjectsByPerson, getAllTeamCode, getAllTeamCodeHelper, + getUserByAutocomplete, + getUserProfileBasicInfo, + updateUserInformation, }; }; diff --git a/src/controllers/warningsController.js b/src/controllers/warningsController.js index 381844883..08515c207 100644 --- a/src/controllers/warningsController.js +++ b/src/controllers/warningsController.js @@ -1,22 +1,45 @@ /* eslint-disable */ const mongoose = require('mongoose'); const userProfile = require('../models/userProfile'); +const currentWarnings = require('../models/currentWarnings'); +const emailSender = require('../utilities/emailSender'); +const userHelper = require('../helpers/userHelper')(); +let currentWarningDescriptions = null; +let currentUserName = null; +const emailTemplate = { + thirdWarning: { + subject: 'Third Warning', + body: `This is the 3rd time the Admin team has requested the same thing from you. Specifically <“tracked area”>. Please carefully review the communications you’ve gotten about this so you understand what is being requested. Ask questions if anything isn’t clear, the Admin team is here to help.
+Please also be sure to fix this from here on forward, asking for the same thing over and over requires administration that really shouldn’t be needed and will result in a blue square if it happens again.
+With Gratitude,
+One Community
`, + }, + fourthWarning: { + subject: 'Fourth Warning', + body: `username !
+This is the 3rd time the Admin team has requested the same thing from you. Specifically <“tracked area”>. Please carefully review the communications you’ve gotten about this so you understand what is being requested. Ask questions if anything isn’t clear, the Admin team is here to help.
+Please also be sure to fix this from here on forward, asking for the same thing over and over requires administration that really shouldn’t be needed and will result in a blue square if it happens again.
+With Gratitude,
+One Community
`, + }, +}; +async function getWarningDescriptions() { + currentWarningDescriptions = await currentWarnings.find({}, { warningTitle: 1, _id: 0 }); +} -const descriptions = [ - 'Better Descriptions', - 'Log Time to Tasks', - 'Log Time as You Go', - 'Log Time to Action Items', - 'Intangible Time Log w/o Reason', -]; const warningsController = function (UserProfile) { const getWarningsByUserId = async function (req, res) { + currentWarningDescriptions = await currentWarnings.find({ + activeWarning: true, + }); + + currentWarningDescriptions = currentWarningDescriptions.map((a) => a.warningTitle); const { userId } = req.params; try { const { warnings } = await UserProfile.findById(userId); - const completedData = filterWarnings(warnings); + const { completedData } = filterWarnings(currentWarningDescriptions, warnings); if (!warnings) { return res.status(400).send({ message: 'no valiud records' }); @@ -32,13 +55,20 @@ const warningsController = function (UserProfile) { const { userId } = req.params; const { iconId, color, date, description } = req.body; + const { monitorData } = req.body; const record = await UserProfile.findById(userId); if (!record) { return res.status(400).send({ message: 'No valid records found' }); } - const updatedWarnings = await userProfile.findByIdAndUpdate( + const userAssignedWarning = { + firstName: record.firstName, + lastName: record.lastName, + email: record.email, + }; + + const updatedWarnings = await UserProfile.findByIdAndUpdate( { _id: userId, }, @@ -46,7 +76,24 @@ const warningsController = function (UserProfile) { { new: true, upsert: true }, ); - const completedData = filterWarnings(updatedWarnings.warnings); + const { completedData, sendEmail, size } = filterWarnings( + currentWarningDescriptions, + updatedWarnings.warnings, + iconId, + color, + ); + + const adminEmails = await getUserRoleByEmail(record); + if (sendEmail !== null) { + sendEmailToUser( + sendEmail, + description, + userAssignedWarning, + monitorData, + size, + adminEmails, + ); + } res.status(201).send({ message: 'success', warnings: completedData }); } catch (error) { @@ -69,8 +116,8 @@ const warningsController = function (UserProfile) { return res.status(400).send({ message: 'no valid records' }); } - const sortedWarnings = filterWarnings(warnings.warnings); - res.status(201).send({ message: 'succesfully deleted', warnings: sortedWarnings }); + const { completedData } = filterWarnings(currentWarningDescriptions, warnings.warnings); + res.status(201).send({ message: 'succesfully deleted', warnings: completedData }); } catch (error) { res.status(401).send({ message: error.message || error }); } @@ -83,19 +130,80 @@ const warningsController = function (UserProfile) { }; }; -// gests the dsecriptions key from the array -const getDescriptionKey = (val) => { - const descriptions = [ - 'Better Descriptions', - 'Log Time to Tasks', - 'Log Time as You Go', - 'Log Time to Action Items', - 'Intangible Time Log w/o Reason', - ]; - - return descriptions.indexOf(val); +//helper to get the team members admin emails +async function getUserRoleByEmail(user) { + //replacement for jae's email + const recipients = ['test@test.com']; + for (const teamId of user.teams) { + const managementEmails = await userHelper.getTeamManagementEmail(teamId); + if (Array.isArray(managementEmails) && managementEmails.length > 0) { + managementEmails.forEach((management) => { + recipients.push(management.email); + }); + } + } + + return [...new Set(recipients)]; +} + +//helper function to get the ordinal +function getOrdinal(n) { + const suffixes = ['th', 'st', 'nd', 'rd']; + const value = n % 100; + return n + (suffixes[(value - 20) % 10] || suffixes[value] || suffixes[0]); +} +const sendEmailToUser = ( + sendEmail, + warningDescription, + userAssignedWarning, + monitorData, + size, + adminEmails, +) => { + const ordinal = getOrdinal(size); + const subjectTitle = ordinal + ' Warning'; + + const currentUserName = `${userAssignedWarning.firstName} ${userAssignedWarning.lastName}`; + const emailTemplate = + sendEmail === 'issue warning' + ? `Hello ${currentUserName},
+This is the ${ordinal} time the Admin team has requested the same thing from you. Specifically, ${warningDescription}. Please carefully review the previous communications you’ve received to fully understand what is being requested. If anything is unclear, don’t hesitate to ask questions—the Admin team is here to assist.
+Moving forward, please ensure this issue is resolved. Repeated requests for the same thing require unnecessary administrative attention and may result in a blue square being issued if it happens again.
+The Admin member who issued the warning is ${monitorData.firstName} ${monitorData.lastName} and their email is ${monitorData.email}. Please comment on your Google Doc and tag them using this email if you have any questions.
+With Gratitude,
+One Community
` + : `Hello ${currentUserName},
+A blue square has been issued because this is the ${ordinal} time the Admin team has requested the same thing from you. Specifically, ${warningDescription}.
+Moving forward, please ensure this is resolved. Repeated requests for the same thing require unnecessary administrative attention, will result in an additional blue square being issued, and could lead to termination.
+Please carefully review the previous communications you’ve received to fully understand what is being requested. If anything is unclear, feel free to ask questions—the Admin team is here to help.
+The Admin member who issued this blue square is ${monitorData.firstName} ${monitorData.lastName} and can be reached at ${monitorData.email}. If you have any questions, please comment on your Google Doc and tag them using this email.
+With Gratitude,
+One Community
`; + + if (sendEmail === 'issue warning') { + emailSender( + `${userAssignedWarning.email}`, + subjectTitle, + emailTemplate, + null, + adminEmails.toString(), + null, + ); + } else if (sendEmail === 'issue blue square') { + emailSender( + `${userAssignedWarning.email}`, + `Blue Square issued for ${warningDescription}`, + null, + emailTemplate, + adminEmails.toString(), + null, + ); + } }; +// gets the dsecriptions key from the array +const getDescriptionKey = (val) => currentWarningDescriptions.indexOf(val); + const sortKeysAlphabetically = (a, b) => getDescriptionKey(a) - getDescriptionKey(b); // method to see which color is first @@ -118,14 +226,29 @@ const sortByColorAndDate = (a, b) => { return colorComparison; }; -const filterWarnings = (warnings) => { +const filterWarnings = (currentWarningDescriptions, warnings, iconId = null, color = null) => { const warningsObject = {}; + let sendEmail = null; + let size = null; + warnings.forEach((warning) => { if (!warningsObject[warning.description]) { warningsObject[warning.description] = []; } warningsObject[warning.description].push(warning); + + if ( + warningsObject[warning.description].length >= 3 && + warning.iconId === iconId && + color === 'yellow' + ) { + sendEmail = 'issue warning'; + size = warningsObject[warning.description].length; + } else if (warning.iconId === iconId && color === 'red') { + sendEmail = 'issue blue square'; + size = warningsObject[warning.description].length; + } }); const warns = Object.keys(warningsObject) @@ -141,13 +264,13 @@ const filterWarnings = (warnings) => { const completedData = []; - for (const descrip of descriptions) { + for (const descrip of currentWarningDescriptions) { completedData.push({ title: descrip, warnings: warns[descrip] ? warns[descrip] : [], }); } - return completedData; + return { completedData, sendEmail, size }; }; module.exports = warningsController; diff --git a/src/cronjobs/userProfileJobs.js b/src/cronjobs/userProfileJobs.js index f0f69e146..e773847d2 100644 --- a/src/cronjobs/userProfileJobs.js +++ b/src/cronjobs/userProfileJobs.js @@ -11,6 +11,7 @@ const userProfileJobs = () => { async () => { const SUNDAY = 0; // will change back to 0 after fix if (moment().tz('America/Los_Angeles').day() === SUNDAY) { + console.log('Running Cron Jobs'); await userhelper.assignBlueSquareForTimeNotMet(); await userhelper.applyMissedHourForCoreTeam(); await userhelper.emailWeeklySummariesForAllUsers(); @@ -19,6 +20,16 @@ const userProfileJobs = () => { } await userhelper.awardNewBadges(); await userhelper.reActivateUser(); + }, + null, + false, + 'America/Los_Angeles', + ); + + // Job to run every day, 1 minute past midnight to deactivate the user + const dailyUserDeactivateJobs = new CronJob( + '1 0 * * *', // Every day, 1 minute past midnight + async () => { await userhelper.deActivateUser(); }, null, @@ -27,5 +38,6 @@ const userProfileJobs = () => { ); allUserProfileJobs.start(); + dailyUserDeactivateJobs.start(); }; module.exports = userProfileJobs; diff --git a/src/helpers/dashboardhelper.js b/src/helpers/dashboardhelper.js index 533dbe367..dddb7cca2 100644 --- a/src/helpers/dashboardhelper.js +++ b/src/helpers/dashboardhelper.js @@ -181,14 +181,11 @@ const dashboardhelper = function () { _myTeam.members.forEach((teamMember) => { if (teamMember.userId.equals(userid) && teamMember.visible) isUserVisible = true; }); - if(isUserVisible) - { + if (isUserVisible) { _myTeam.members.forEach((teamMember) => { - if (!teamMember.userId.equals(userid)) - teamMemberIds.push(teamMember.userId); - }); - } - + if (!teamMember.userId.equals(userid)) teamMemberIds.push(teamMember.userId); + }); + } }); teamMembers = await userProfile.find( @@ -203,8 +200,8 @@ const dashboardhelper = function () { timeOffFrom: 1, timeOffTill: 1, endDate: 1, - } - + missedHours: 1, + }, ); } else { // 'Core Team', 'Owner' //All users @@ -220,7 +217,7 @@ const dashboardhelper = function () { timeOffFrom: 1, timeOffTill: 1, endDate: 1, - + missedHours: 1, }, ); } @@ -269,6 +266,7 @@ const dashboardhelper = function () { ? teamMember.weeklySummaries[0].summary !== '' : false, weeklycommittedHours: teamMember.weeklycommittedHours, + missedHours: teamMember.missedHours ?? 0, totaltangibletime_hrs: (timeEntryByPerson[teamMember._id.toString()]?.tangibleSeconds ?? 0) / 3600, totalintangibletime_hrs: @@ -309,255 +307,6 @@ const dashboardhelper = function () { console.log(error); return new Error(error); } - - // return myTeam.aggregate([ - // { - // $match: { - // _id: userid, - // }, - // }, - // { - // $unwind: '$myteam', - // }, - // { - // $project: { - // _id: 0, - // role: 1, - // personId: '$myteam._id', - // name: '$myteam.fullName', - // }, - // }, - // { - // $lookup: { - // from: 'userProfiles', - // localField: 'personId', - // foreignField: '_id', - // as: 'persondata', - // }, - // }, - // { - // $match: { - // // leaderboard user roles hierarchy - // $or: [ - // { - // role: { $in: ['Owner', 'Core Team'] }, - // }, - // { - // $and: [ - // { - // role: 'Administrator', - // }, - // { 'persondata.0.role': { $nin: ['Owner', 'Administrator'] } }, - // ], - // }, - // { - // $and: [ - // { - // role: { $in: ['Manager', 'Mentor'] }, - // }, - // { - // 'persondata.0.role': { - // $nin: ['Manager', 'Mentor', 'Core Team', 'Administrator', 'Owner'], - // }, - // }, - // ], - // }, - // { 'persondata.0._id': userId }, - // { 'persondata.0.role': 'Volunteer' }, - // { 'persondata.0.isVisible': true }, - // ], - // }, - // }, - // { - // $project: { - // personId: 1, - // name: 1, - // role: { - // $arrayElemAt: ['$persondata.role', 0], - // }, - // isVisible: { - // $arrayElemAt: ['$persondata.isVisible', 0], - // }, - // hasSummary: { - // $ne: [ - // { - // $arrayElemAt: [ - // { - // $arrayElemAt: ['$persondata.weeklySummaries.summary', 0], - // }, - // 0, - // ], - // }, - // '', - // ], - // }, - // weeklycommittedHours: { - // $sum: [ - // { - // $arrayElemAt: ['$persondata.weeklycommittedHours', 0], - // }, - // { - // $ifNull: [{ $arrayElemAt: ['$persondata.missedHours', 0] }, 0], - // }, - // ], - // }, - // }, - // }, - // { - // $lookup: { - // from: 'timeEntries', - // localField: 'personId', - // foreignField: 'personId', - // as: 'timeEntryData', - // }, - // }, - // { - // $project: { - // personId: 1, - // name: 1, - // role: 1, - // isVisible: 1, - // hasSummary: 1, - // weeklycommittedHours: 1, - // timeEntryData: { - // $filter: { - // input: '$timeEntryData', - // as: 'timeentry', - // cond: { - // $and: [ - // { - // $gte: ['$$timeentry.dateOfWork', pdtstart], - // }, - // { - // $lte: ['$$timeentry.dateOfWork', pdtend], - // }, - // ], - // }, - // }, - // }, - // }, - // }, - // { - // $unwind: { - // path: '$timeEntryData', - // preserveNullAndEmptyArrays: true, - // }, - // }, - // { - // $project: { - // personId: 1, - // name: 1, - // role: 1, - // isVisible: 1, - // hasSummary: 1, - // weeklycommittedHours: 1, - // totalSeconds: { - // $cond: [ - // { - // $gte: ['$timeEntryData.totalSeconds', 0], - // }, - // '$timeEntryData.totalSeconds', - // 0, - // ], - // }, - // isTangible: { - // $cond: [ - // { - // $gte: ['$timeEntryData.totalSeconds', 0], - // }, - // '$timeEntryData.isTangible', - // false, - // ], - // }, - // }, - // }, - // { - // $addFields: { - // tangibletime: { - // $cond: [ - // { - // $eq: ['$isTangible', true], - // }, - // '$totalSeconds', - // 0, - // ], - // }, - // intangibletime: { - // $cond: [ - // { - // $eq: ['$isTangible', false], - // }, - // '$totalSeconds', - // 0, - // ], - // }, - // }, - // }, - // { - // $group: { - // _id: { - // personId: '$personId', - // weeklycommittedHours: '$weeklycommittedHours', - // name: '$name', - // role: '$role', - // isVisible: '$isVisible', - // hasSummary: '$hasSummary', - // }, - // totalSeconds: { - // $sum: '$totalSeconds', - // }, - // tangibletime: { - // $sum: '$tangibletime', - // }, - // intangibletime: { - // $sum: '$intangibletime', - // }, - // }, - // }, - // { - // $project: { - // _id: 0, - // personId: '$_id.personId', - // name: '$_id.name', - // role: '$_id.role', - // isVisible: '$_id.isVisible', - // hasSummary: '$_id.hasSummary', - // weeklycommittedHours: '$_id.weeklycommittedHours', - // totaltime_hrs: { - // $divide: ['$totalSeconds', 3600], - // }, - // totaltangibletime_hrs: { - // $divide: ['$tangibletime', 3600], - // }, - // totalintangibletime_hrs: { - // $divide: ['$intangibletime', 3600], - // }, - // percentagespentintangible: { - // $cond: [ - // { - // $eq: ['$totalSeconds', 0], - // }, - // 0, - // { - // $multiply: [ - // { - // $divide: ['$tangibletime', '$totalSeconds'], - // }, - // 100, - // ], - // }, - // ], - // }, - // }, - // }, - // { - // $sort: { - // totaltangibletime_hrs: -1, - // name: 1, - // role: 1, - // }, - // }, - // ]); }; /** diff --git a/src/helpers/taskHelper.js b/src/helpers/taskHelper.js index 34fb36be8..fefa9f021 100644 --- a/src/helpers/taskHelper.js +++ b/src/helpers/taskHelper.js @@ -112,9 +112,15 @@ const taskHelper = function () { ); sharedTeamsResult.forEach((_myTeam) => { + let hasTeamVisibility = false; _myTeam.members.forEach((teamMember) => { - if (!teamMember.userId.equals(userid)) teamMemberIds.push(teamMember.userId); + if (teamMember.userId.equals(userid) && teamMember.visible) hasTeamVisibility = true; }); + if (hasTeamVisibility) { + _myTeam.members.forEach((teamMember) => { + if (!teamMember.userId.equals(userid)) teamMemberIds.push(teamMember.userId); + }); + } }); teamMembers = await userProfile diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index ed9c52131..168d704b1 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -146,14 +146,14 @@ const userHelper = function () { .localeData() .ordinal( totalInfringements, - )} blue square of 5 and that means you have ${totalInfringements - 5} hour(s) added to your - requirement this week. This is in addition to any hours missed for last week: - ${weeklycommittedHours} hours commitment + ${remainHr} hours owed for last week + ${totalInfringements - 5} hours + )} blue square of 5 and that means you have ${totalInfringements - 5} hour(s) added to your + requirement this week. This is in addition to any hours missed for last week: + ${weeklycommittedHours} hours commitment + ${remainHr} hours owed for last week + ${totalInfringements - 5} hours owed for this being your ${moment .localeData() .ordinal( totalInfringements, - )} blue square = ${hrThisweek + totalInfringements - 5} hours required for this week. + )} blue square = ${hrThisweek + totalInfringements - 5} hours required for this week. .`; } // bold description for 'System auto-assigned infringement for two reasons ....' and 'not submitting a weekly summary' and logged hrs @@ -204,7 +204,7 @@ const userHelper = function () {One Community
-ADMINISTRATIVE DETAILS:
Start Date: ${administrativeContent.startDate}
Role: ${administrativeContent.role}
@@ -656,7 +656,7 @@ const userHelper = function () { } // No extra hours is needed if blue squares isn't over 5. // length +1 is because new infringement hasn't been created at this stage. - const coreTeamExtraHour = Math.max(0, oldInfringements.length - 5); + const coreTeamExtraHour = Math.max(0, oldInfringements.length + 1 - 5); const utcStartMoment = moment(pdtStartOfLastWeek).add(1, 'second'); const utcEndMoment = moment(pdtEndOfLastWeek).subtract(1, 'day').subtract(1, 'second'); @@ -703,7 +703,7 @@ const userHelper = function () { .localeData() .ordinal( oldInfringements.length + 1, - )} blue square. So you should have completed ${weeklycommittedHours} hours and you completed ${timeSpent.toFixed( + )} blue square. So you should have completed ${weeklycommittedHours + coreTeamExtraHour} hours and you completed ${timeSpent.toFixed( 2, )} hours.`; } else { @@ -727,7 +727,7 @@ const userHelper = function () { .localeData() .ordinal( oldInfringements.length + 1, - )} blue square. So you should have completed ${weeklycommittedHours} hours and you completed ${timeSpent.toFixed( + )} blue square. So you should have completed ${weeklycommittedHours + coreTeamExtraHour} hours and you completed ${timeSpent.toFixed( 2, )} hours.`; } else { @@ -956,29 +956,54 @@ const userHelper = function () { $project: { _id: 1, missedHours: { - $max: [ - { - $subtract: [ - { - $sum: [{ $ifNull: ['$missedHours', 0] }, '$weeklycommittedHours'], - }, - { - $divide: [ - { - $sum: { - $map: { - input: '$timeEntries', - in: '$$this.totalSeconds', - }, + $let: { + vars: { + baseMissedHours: { + $max: [ + { + $subtract: [ + { + $sum: [{ $ifNull: ['$missedHours', 0] }, '$weeklycommittedHours'], }, - }, - 3600, - ], - }, + { + $divide: [ + { + $sum: { + $map: { + input: '$timeEntries', + in: '$$this.totalSeconds', + }, + }, + }, + 3600, + ], + }, + ], + }, + 0, + ], + }, + infringementsAdjustment: { + $cond: [ + { + $and: [ + { $gt: ['$infringements', null] }, + { $gt: [{ $size: '$infringements' }, 5] }, + ], + }, + { $subtract: [{ $size: '$infringements' }, 5] }, + 0, + ], + }, + }, + in: { + $cond: [ + { $gt: ['$$baseMissedHours', 0] }, + { $add: ['$$baseMissedHours', '$$infringementsAdjustment'] }, + '$$baseMissedHours', ], }, - 0, - ], + }, }, }, }, @@ -1024,7 +1049,7 @@ const userHelper = function () { }, ); - logger.logInfo(`Job deleting blue squares older than 1 year finished + logger.logInfo(`Job deleting blue squares older than 1 year finished at ${moment().tz('America/Los_Angeles').format()} \nReulst: ${JSON.stringify(results)}`); } catch (err) { logger.logException(err); @@ -1074,11 +1099,11 @@ const userHelper = function () { const emailBody = `Hi Admin!
This email is to let you know that ${person.firstName} ${person.lastName} has been made active again in the Highest Good Network application after being paused on ${endDate}.
- +If you need to communicate anything with them, this is their email from the system: ${person.email}.
- +Thanks!
- +The HGN A.I. (and One Community)
`; emailSender('onecommunityglobal@gmail.com', subject, emailBody, null, null, person.email); @@ -2037,33 +2062,71 @@ const userHelper = function () { email, recipients, isSet, + reactivationDate, + sendThreeWeeks, + followup, ) { - if (endDate && !isSet) { - const subject = `IMPORTANT: ${firstName} ${lastName} has been deactivated in the Highest Good Network`; - const emailBody = `Management,
+ let subject; + let emailBody; + recipients.push('onecommunityglobal@gmail.com'); + recipients = recipients.toString(); + if (reactivationDate) { + subject = `IMPORTANT: ${firstName} ${lastName} has been PAUSED in the Highest Good Network`; + emailBody = `Management,
+ +Please note that ${firstName} ${lastName} has been PAUSED in the Highest Good Network as ${moment(endDate).format('M-D-YYYY')}.
+For a smooth transition, Please confirm all your work with this individual has been wrapped up and nothing further is needed on their part until they return on ${moment(reactivationDate).format('M-D-YYYY')}.
-Please note that ${firstName} ${lastName} has been made inactive in the Highest Good Network as of ${endDate}. - Please confirm all your work with this individual has been wrapped up and nothing further is needed on their part.
-With Gratitude,
- +One Community
`; - recipients.push('onecommunityglobal@gmail.com'); - recipients = recipients.toString(); - emailSender(recipients, subject, emailBody, null, null, email); - } else if (isSet) { - const subject = `IMPORTANT: ${firstName} ${lastName} has been deactivated in the Highest Good Network`; + emailSender(email, subject, emailBody, null, recipients, email); + } else if (endDate && isSet && sendThreeWeeks) { + const subject = `IMPORTANT: The last day for ${firstName} ${lastName} has been set in the Highest Good Network`; const emailBody = `Management,
-Please note that the final day for ${firstName} ${lastName} has been set in the Highest Good Network ${endDate}. - For a smooth transition, please confirm all your work is being wrapped up with this individual and nothing further will be needed on their part after this date.
- +Please note that the final day for ${firstName} ${lastName} has been set in the Highest Good Network as ${moment(endDate).format('M-D-YYYY')}.
+This is more than 3 weeks from now, but you should still start confirming all your work is being wrapped up with this individual and nothing further will be needed on their part after this date.
+ +An additional reminder email will be sent in their final 2 weeks.
+ +With Gratitude,
+ +One Community
`; + emailSender(email, subject, emailBody, null, recipients, email); + } else if (endDate && isSet && followup) { + subject = `IMPORTANT: The last day for ${firstName} ${lastName} has been set in the Highest Good Network`; + emailBody = `Management,
+ +Please note that the final day for ${firstName} ${lastName} has been set in the Highest Good Network as ${moment(endDate).format('M-D-YYYY')}.
+This is coming up soon. For a smooth transition, please confirm all your work is wrapped up with this individual and nothing further will be needed on their part after this date.
+ +With Gratitude,
+ +One Community
`; + emailSender(email, subject, emailBody, null, recipients, email); + } else if (endDate && isSet) { + subject = `IMPORTANT: The last day for ${firstName} ${lastName} has been set in the Highest Good Network`; + emailBody = `Management,
+ +Please note that the final day for ${firstName} ${lastName} has been set in the Highest Good Network as ${moment(endDate).format('M-D-YYYY')}.
+For a smooth transition, Please confirm all your work with this individual has been wrapped up and nothing further is needed on their part.
+With Gratitude,
- + +One Community
`; + emailSender(email, subject, emailBody, null, recipients, email); + } else if (endDate) { + subject = `IMPORTANT: ${firstName} ${lastName} has been deactivated in the Highest Good Network`; + emailBody = `Management,
+ +Please note that ${firstName} ${lastName} has been made inactive in the Highest Good Network as ${moment(endDate).format('M-D-YYYY')}.
+For a smooth transition, Please confirm all your work with this individual has been wrapped up and nothing further is needed on their part.
+ +With Gratitude,
+One Community
`; - recipients.push('onecommunityglobal@gmail.com'); - recipients = recipients.toString(); - emailSender(recipients, subject, emailBody, null, null, email); + emailSender(email, subject, emailBody, null, recipients, email); } }; @@ -2076,13 +2139,42 @@ const userHelper = function () { const recipients = emailReceivers.map((receiver) => receiver.email); const users = await userProfile.find( { isActive: true, endDate: { $exists: true } }, - '_id isActive endDate isSet', + '_id isActive endDate isSet finalEmailThreeWeeksSent reactivationDate', ); for (let i = 0; i < users.length; i += 1) { const user = users[i]; - const { endDate } = user; + const { endDate, finalEmailThreeWeeksSent } = user; endDate.setHours(endDate.getHours() + 7); - if (moment().isAfter(moment(endDate).add(1, 'days'))) { + // notify reminder set final day before 2 weeks + if ( + finalEmailThreeWeeksSent && + moment().isBefore(moment(endDate).subtract(2, 'weeks')) && + moment().isAfter(moment(endDate).subtract(3, 'weeks')) + ) { + const id = user._id; + const person = await userProfile.findById(id); + const lastDay = moment(person.endDate).format('YYYY-MM-DD'); + logger.logInfo(`User with id: ${user._id}'s final Day is set at ${moment().format()}.`); + person.teams.map(async (teamId) => { + const managementEmails = await userHelper.getTeamManagementEmail(teamId); + if (Array.isArray(managementEmails) && managementEmails.length > 0) { + managementEmails.forEach((management) => { + recipients.push(management.email); + }); + } + }); + sendDeactivateEmailBody( + person.firstName, + person.lastName, + lastDay, + person.email, + recipients, + person.isSet, + person.reactivationDate, + false, + true, + ); + } else if (moment().isAfter(moment(endDate).add(1, 'days'))) { try { await userProfile.findByIdAndUpdate( user._id, @@ -2115,6 +2207,8 @@ const userHelper = function () { person.email, recipients, person.isSet, + person.reactivationDate, + undefined, ); } } diff --git a/src/models/currentWarnings.js b/src/models/currentWarnings.js new file mode 100644 index 000000000..18a446199 --- /dev/null +++ b/src/models/currentWarnings.js @@ -0,0 +1,11 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const currentWarnings = new Schema({ + warningTitle: { type: String, required: true }, + activeWarning: { type: Boolean, required: true }, + isPermanent: { type: Boolean, required: true }, +}); + +module.exports = mongoose.model('currentWarning', currentWarnings, 'currentWarnings'); diff --git a/src/models/jobs.js b/src/models/jobs.js new file mode 100644 index 000000000..b1f98a34a --- /dev/null +++ b/src/models/jobs.js @@ -0,0 +1,17 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const jobSchema = new Schema({ + title: { type: String, required: true }, // Job title + category: { type: String, required: true }, // General category (e.g., Engineering, Marketing) + description: { type: String, required: true }, // Detailed job description + imageUrl: { type: String, required: true }, // URL of the job-related image + location: { type: String, required: true }, // Job location (optional for remote jobs) + applyLink: { type: String, required: true }, // URL for the application form + featured: { type: Boolean, default: false }, // Whether the job should be featured prominently + datePosted: { type: Date, default: Date.now }, // Date the job was posted + jobDetailsLink: { type: String, required: true }, // Specific job details URL +}); + +module.exports = mongoose.model('Job', jobSchema); diff --git a/src/models/team.js b/src/models/team.js index 4d73615f5..109d93221 100644 --- a/src/models/team.js +++ b/src/models/team.js @@ -3,9 +3,9 @@ const mongoose = require('mongoose'); const { Schema } = mongoose; /** - * This schema represents a team in the system. - * - * Deprecated field: teamCode. Team code is no longer associated with a team. + * This schema represents a team in the system. + * + * Deprecated field: teamCode. Team code is no longer associated with a team. * Team code is used as a text string identifier in the user profile data model. */ const team = new Schema({ @@ -15,9 +15,10 @@ const team = new Schema({ modifiedDatetime: { type: Date, default: Date.now() }, members: [ { - userId: { type: mongoose.SchemaTypes.ObjectId, required: true }, + userId: { type: mongoose.SchemaTypes.ObjectId, required: true, index : true }, addDateTime: { type: Date, default: Date.now(), ref: 'userProfile' }, visible: { type : 'Boolean', default:true}, + }, ], // Deprecated field @@ -35,4 +36,5 @@ const team = new Schema({ }, }); + module.exports = mongoose.model('team', team, 'teams'); diff --git a/src/models/timeentry.js b/src/models/timeentry.js index ea5303b3a..1535ab13e 100644 --- a/src/models/timeentry.js +++ b/src/models/timeentry.js @@ -17,5 +17,7 @@ const TimeEntry = new Schema({ lastModifiedDateTime: { type: Date, default: Date.now }, isActive: { type: Boolean, default: true }, }); +TimeEntry.index({ personId: 1, dateOfWork: 1 }); +TimeEntry.index({ entryType: 1, teamId: 1, dateOfWork: 1, isActive: 1 }); module.exports = mongoose.model('timeEntry', TimeEntry, 'timeEntries'); diff --git a/src/models/title.js b/src/models/title.js index 64b9aed92..a41063aea 100644 --- a/src/models/title.js +++ b/src/models/title.js @@ -4,6 +4,7 @@ const { Schema } = mongoose; const title = new Schema({ titleName: { type: String, required: true }, + titleCode: { type: String, required: true }, teamCode: { type: String, require: true }, projectAssigned: { projectName: { type: String, required: true }, @@ -13,8 +14,7 @@ const title = new Schema({ teamAssiged: { teamName: { type: String }, _id: { type: String }, - }, - shortName: { type: String, require: true }, + }, }); diff --git a/src/models/userProfile.js b/src/models/userProfile.js index cc7136f54..61e8e8f91 100644 --- a/src/models/userProfile.js +++ b/src/models/userProfile.js @@ -27,6 +27,7 @@ const userProfileSchema = new Schema({ isActive: { type: Boolean, required: true, default: true }, isRehireable: { type: Boolean, default: true }, isSet: { type: Boolean, required: true, default: false }, + finalEmailThreeWeeksSent: { type: Boolean, required: true, default: false }, role: { type: String, required: true, @@ -75,7 +76,7 @@ const userProfileSchema = new Schema({ startDate: { type: Date, required: true, - default () { + default() { return this.createdDate; }, }, @@ -127,6 +128,7 @@ const userProfileSchema = new Schema({ required: true, default: 'white', }, + iconId: { type: String, required: true }, }, ], location: { diff --git a/src/routes/curentWarningsRouter.js b/src/routes/curentWarningsRouter.js new file mode 100644 index 000000000..f1a004493 --- /dev/null +++ b/src/routes/curentWarningsRouter.js @@ -0,0 +1,23 @@ +const express = require('express'); + +const route = function (currentWarnings) { + const controller = require('../controllers/currentWarningsController')(currentWarnings); + + const currentWarningsRouter = express.Router(); + + currentWarningsRouter + .route('/currentWarnings') + .get(controller.getCurrentWarnings) + .post(controller.postNewWarningDescription); + + currentWarningsRouter.route('/currentWarnings/edit').put(controller.editWarningDescription); + + currentWarningsRouter + .route('/currentWarnings/:warningDescriptionId') + .delete(controller.deleteWarningDescription) + .put(controller.updateWarningDescription); + + return currentWarningsRouter; +}; + +module.exports = route; diff --git a/src/routes/informationRouter.test.js b/src/routes/informationRouter.test.js new file mode 100644 index 000000000..12a600723 --- /dev/null +++ b/src/routes/informationRouter.test.js @@ -0,0 +1,145 @@ +const request = require('supertest'); +const { jwtPayload } = require('../test'); +const cache = require('../utilities/nodeCache')(); +const { app } = require('../app'); +const { + mockReq, + createUser, + mongoHelper: { dbConnect, dbDisconnect, dbClearCollections, dbClearAll }, +} = require('../test'); + +const agent = request.agent(app); + +describe('information routes', () => { + let user; + let token; + let reqBody = { + ...mockReq.body, + }; + beforeAll(async () => { + await dbConnect(); + user = await createUser(); + token = jwtPayload(user); + reqBody = { + ...reqBody, + infoName: 'some infoName', + infoContent: 'some infoContent', + visibility: '1', + }; + }); + beforeEach(async () => { + await dbClearCollections('informations'); + }); + + afterAll(async () => { + await dbClearAll(); + await dbDisconnect(); + }); + describe('informationRoutes', () => { + it('should return 401 if authorization header is not present', async () => { + await agent.post('/api/informations').send(reqBody).expect(401); + await agent.get('/api/informations/randomID').send(reqBody).expect(401); + }); + }); + describe('Post Information route', () => { + it('Should return 201 if the information is successfully added', async () => { + const response = await agent + .post('/api/informations') + .send(reqBody) + .set('Authorization', token) + .expect(201); + + expect(response.body).toEqual({ + _id: expect.anything(), + __v: expect.anything(), + infoName: reqBody.infoName, + infoContent: reqBody.infoContent, + visibility: reqBody.visibility, + }); + }); + }); + describe('Get Information route', () => { + it('Should return 201 if the information is successfully added', async () => { + const informations = [ + { + _id: '6605f860f948db61dab6f27m', + infoName: 'get info', + infoContent: 'get infoConten', + visibility: '1', + }, + ]; + cache.setCache('informations', JSON.stringify(informations)); + const response = await agent + .get('/api/informations') + .send(reqBody) + .set('Authorization', token) + .expect(200); + expect(response.body).toEqual({}); + }); + }); + describe('Delete Information route', () => { + it('Should return 400 if the route does not exist', async () => { + await agent + .delete('/api/informations/random123') + .send(reqBody) + .set('Authorization', token) + .expect(400); + }); + // thrown: "Exceeded timeout of 5000 ms for a test. + // Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." + // it('Should return 200 if deleting successfully', async () => { + // const _info = new Information(); + // _info.infoName = reqBody.infoName; + // _info.infoContent = reqBody.infoContent; + // _info.visibility = reqBody.visibility; + // const info = await _info.save(); + // const response = await agent + // .delete(`/api/informations/${info._id}`) + // .set('Authorization', token) + // .send(reqBody) + // .expect(200); + + // expect(response.body).toEqual( + // { + // _id: expect.anything(), + // __v: expect.anything(), + // infoName: info.infoName, + // infoContent: info.infoContent, + // visibility: info.visibility, + // }); + // }); + }); + describe('Update Information route', () => { + it('Should return 400 if the route does not exist', async () => { + await agent + .put('/api/informations/random123') + .send(reqBody) + .set('Authorization', token) + .expect(400); + }); + // thrown: "Exceeded timeout of 5000 ms for a test. + // Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout." + // it('Should return 200 if udapted successfully', async () => { + // const _info = new Information(); + // _info.infoName = reqBody.infoName; + // _info.infoContent = reqBody.infoContent; + // _info.visibility = reqBody.visibility; + // const info = await _info.save(); + + // const response = await agent + // .put(`/api/informations/${info.id}`) + // .send(reqBody) + // .set('Authorization', token) + // .expect(200); + // expect(response.body).toEqual( + // { + // _id: expect.anything(), + // __v: expect.anything(), + // infoName: info.infoName, + // infoContent: info.infoContent, + // visibility: info.visibility, + // }); + + // }); + }); +}); diff --git a/src/routes/jobsRouter.js b/src/routes/jobsRouter.js new file mode 100644 index 000000000..5b66735a6 --- /dev/null +++ b/src/routes/jobsRouter.js @@ -0,0 +1,14 @@ +const express = require('express'); +const jobsController = require('../controllers/jobsController'); // Adjust the path if needed + +const router = express.Router(); + +// Define routes +router.get('/', jobsController.getJobs); +router.get('/categories', jobsController.getCategories); +router.get('/:id', jobsController.getJobById); +router.post('/', jobsController.createJob); +router.put('/:id', jobsController.updateJob); +router.delete('/:id', jobsController.deleteJob); + +module.exports = router; diff --git a/src/routes/reasonRouter.test.js b/src/routes/reasonRouter.test.js new file mode 100644 index 000000000..a1f1ab6dc --- /dev/null +++ b/src/routes/reasonRouter.test.js @@ -0,0 +1,338 @@ +const request = require('supertest'); +const moment = require('moment-timezone'); +const { jwtPayload } = require('../test'); +const cache = require('../utilities/nodeCache')(); +const { app } = require('../app'); +const { + mockReq, + mockUser, + createUser, + createTestPermissions, + mongoHelper: { dbConnect, dbDisconnect, dbClearCollections, dbClearAll }, +} = require('../test'); +// const Reason = require('../models/reason'); + +function mockDay(dayIdx, past = false) { + const date = moment().tz('America/Los_Angeles').startOf('day'); + while (date.day() !== dayIdx) { + date.add(past ? -1 : 1, 'days'); + } + return date; +} +const agent = request.agent(app); +describe('reason routers', () => { + let adminUser; + let adminToken; + let reqBody = { + body: { + ...mockReq.body, + ...mockUser(), + }, + }; + beforeAll(async () => { + await dbConnect(); + await createTestPermissions(); + adminUser = await createUser(); + adminToken = jwtPayload(adminUser); + }); + beforeEach(async () => { + await dbClearCollections('reason'); + await dbClearCollections('userProfiles'); + cache.setCache('allusers', '[]'); + reqBody = { + body: { + ...mockReq.body, + ...mockUser(), + reasonData: { + date: mockDay(0), + message: 'some reason', + }, + currentDate: moment.tz('America/Los_Angeles').startOf('day'), + }, + }; + }); + afterAll(async () => { + await dbClearAll(); + await dbDisconnect(); + }); + describe('reasonRouters', () => { + it('should return 401 if authorization header is not present', async () => { + await agent.post('/api/reason/').send(reqBody.body).expect(401); + await agent.get('/api/reason/randomId').send(reqBody.body).expect(401); + await agent.get('/api/reason/single/randomId').send(reqBody.body).expect(401); + await agent.patch('/api/reason/randomId/').send(reqBody.body).expect(401); + await agent.delete('/api/reason/randomId').send(reqBody.body).expect(401); + }); + }); + describe('Post reason route', () => { + it('Should return 400 if user did not choose SUNDAY', async () => { + reqBody.body.reasonData.date = mockDay(1, true); + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: + "You must choose the Sunday YOU'LL RETURN as your date. This is so your reason ends up as a note on that blue square.", + errorCode: 0, + }); + }); + it('Should return 400 if warning to choose a future date', async () => { + reqBody.body.reasonData.date = mockDay(0, true); + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: 'You should select a date that is yet to come', + errorCode: 7, + }); + }); + it('Should return 400 if not providing reason', async () => { + reqBody.body.reasonData.message = null; + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + message: 'You must provide a reason.', + errorCode: 6, + }); + }); + it('Should return 404 if error in finding user Id', async () => { + reqBody.body.userId = null; + const response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 403 if duplicate resonse', async () => { + // const userProfile = new userPro + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(403); + }); + it('Should return 200 if post successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post('/api/reason/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Get AllReason route', () => { + it('Should return 400 if route does not exist', async () => { + const response = await agent + .get(`/api/reason/random123`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(400); + expect(response.body).toEqual({ + errMessage: 'Something went wrong while fetching the user', + }); + }); + it('Should return 200 if get all reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .get(`/api/reason/${userId}`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Get Single Reason route', () => { + it('Should return 400 if route does not exist', async () => { + reqBody.query = { + queryDate: mockDay(1, true), + }; + const response = await agent + .get(`/api/reason/single/5a7e21f00317bc1538def4b9`) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 200 if get all reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + reqBody.query = { + queryDate: mockDay(1, true), + }; + response = await agent + .get(`/api/reason/single/${userId}`) + .set('Authorization', adminToken) + .expect(200); + }); + }); + describe('Patch reason route', () => { + it('Should return 404 if error in finding user Id', async () => { + reqBody.body.userId = null; + const response = await agent + .patch('/api/reason/5a7e21f00317bc1538def4b9/') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 404 if duplicate reasons', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .patch(`/api/reason/${userId}/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'Reason not found', + errorCode: 4, + }); + }); + it('Should return 200 if patch successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent.get('/api/userProfile').set('Authorization', adminToken).expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post(`/api/reason/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .patch(`/api/reason/${userId}/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toEqual({ + message: 'Reason Updated!', + }); + }); + }); + describe('Delete reason route', () => { + it('Should return 404 if route does not exist', async () => { + const response = await agent + .delete(`/api/reason/5a7e21f00317bc1538def4b9`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + expect(response.body).toEqual({ + message: 'User not found', + errorCode: 2, + }); + }); + it('Should return 200 if deleting successfully', async () => { + let response = await agent + .post('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + + expect(response.body).toBeTruthy(); + response = await agent + .get('/api/userProfile') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + const userId = response.body[0]._id; + reqBody.body.userId = userId; + response = await agent + .post(`/api/reason/`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toBeTruthy(); + response = await agent + .delete(`/api/reason/${userId}`) + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(200); + expect(response.body).toEqual({ + message: 'Document deleted', + }); + }); + }); +}); diff --git a/src/routes/teamRouter.js b/src/routes/teamRouter.js index 1bf8cfc44..3fbd8abb5 100644 --- a/src/routes/teamRouter.js +++ b/src/routes/teamRouter.js @@ -11,6 +11,10 @@ const router = function (team) { .post(controller.postTeam) .put(controller.updateTeamVisibility); + teamRouter + .route("/team/reports") + .post(controller.getAllTeamMembers); + teamRouter .route('/team/:teamId') .get(controller.getTeamById) diff --git a/src/routes/timeZoneAPIRoutes.test.js b/src/routes/timeZoneAPIRoutes.test.js new file mode 100644 index 000000000..975e56ac2 --- /dev/null +++ b/src/routes/timeZoneAPIRoutes.test.js @@ -0,0 +1,208 @@ +const request = require('supertest'); +const { jwtPayload } = require('../test'); + +const originalPremiumKey = process.env.TIMEZONE_PREMIUM_KEY; + +const { app } = require('../app'); +const { + mockReq, + mongoHelper: { dbConnect, dbDisconnect }, + createTestPermissions, + createUser, + mockUser, +} = require('../test'); + +const UserProfile = require('../models/userProfile'); +const ProfileInitialSetupToken = require('../models/profileInitialSetupToken'); + +const agent = request.agent(app); + +describe('timeZoneAPI routes', () => { + let adminUser; + let adminToken; + let volunteerUser; + let volunteerToken; + + const reqBody = {}; + const incorrectLocationParams = 'r'; + const locationParamsThatResultsInNoMatch = 'someReallyRandomLocation'; + const correctLocationParams = 'Berlin,+Germany'; + + beforeAll(async () => { + await dbConnect(); + await createTestPermissions(); + + reqBody.body = { + // This is the user we want to create + ...mockReq.body, + }; + adminUser = await createUser(); // This is the admin requestor user + adminToken = jwtPayload(adminUser); + + volunteerUser = mockUser(); // This is the admin requestor user + volunteerUser.email = 'volunteer@onecommunity.com'; + volunteerUser.role = 'Volunteer'; + volunteerUser = new UserProfile(volunteerUser); + volunteerUser = await volunteerUser.save(); + volunteerToken = jwtPayload(volunteerUser); + }); + + afterAll(async () => { + await dbDisconnect(); + + if (originalPremiumKey) { + process.env.TIMEZONE_PREMIUM_KEY = originalPremiumKey; + } else { + delete process.env.TIMEZONE_PREMIUM_KEY; + } + }); + + describe('API routes', () => { + it("should return 404 if route doesn't exist", async () => { + await agent + .post('/api/timezonesss') + .send(reqBody.body) + .set('Authorization', adminToken) + .expect(404); + }); + }); + + describe('getTimeZone - request parameter `location` based tests', () => { + test('401 when `API key` is missing', async () => { + const location = 'Berlin,+Germany'; + delete process.env.TIMEZONE_PREMIUM_KEY; + + const response = await agent + .get(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(401); + + expect(response.error.text).toBe('API Key Missing'); + }); + + test('400 when `location` is incorrect', async () => { + const response = await agent + .get(`/api/timezone/${incorrectLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBeTruthy(); + }); + + test('200 when `location` is correctly formatted', async () => { + const response = await agent + .get(`/api/timezone/${correctLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(200); + + expect(response).toBeTruthy(); + expect(response._body.timezone).toBeTruthy(); + expect(response._body.currentLocation).toBeTruthy(); + expect(response._body.currentLocation.userProvided).toBe(correctLocationParams); + }); + + test('404 when results.length === 0', async () => { + const response = await agent + .get(`/api/timezone/${locationParamsThatResultsInNoMatch}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(404); + + expect(response).toBeTruthy(); + }); + }); + + describe('getTimeZoneProfileInitialSetup - token is missing in body or in ProfileInitialSetupToken', () => { + test('401 when `token` is missing in request body', async () => { + const location = 'Berlin,+Germany'; + + const response = await agent + .post(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBe('Missing token'); + }); + + test('403 when ProfileInitialSetupToken does not contains `req.body.token`', async () => { + const location = 'Berlin,+Germany'; + reqBody.body = { + ...reqBody, + token: 'randomToken', + }; + + const response = await agent + .post(`/api/timezone/${location}`) + .set('Authorization', adminToken) + .send(reqBody.body) + .expect(403); + + expect(response.error.text).toBe('Unauthorized Request'); + }); + }); + + describe('getTimeZoneProfileInitialSetup - token is present in ProfileInitialSetupToken', () => { + const tokenData = 'randomToken'; + + beforeAll(async () => { + const expirationDate = new Date().setDate(new Date().getDate() + 10); + + let data = { + token: tokenData, + email: 'randomEmail', + weeklyCommittedHours: 5, + expiration: expirationDate, + createdDate: new Date(), + isCancelled: false, + isSetupCompleted: true, + }; + + data = new ProfileInitialSetupToken(data); + + // eslint-disable-next-line no-unused-vars + data = await data.save(); + + reqBody.body = { + ...reqBody, + token: tokenData, + }; + }); + + test('400 when `location` is incorrect', async () => { + const response = await agent + .get(`/api/timezone/${incorrectLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(400); + + expect(response.error.text).toBeTruthy(); + }); + + test('200 when `location` is correctly formatted', async () => { + const response = await agent + .get(`/api/timezone/${correctLocationParams}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(200); + + expect(response).toBeTruthy(); + expect(response._body.timezone).toBeTruthy(); + expect(response._body.currentLocation).toBeTruthy(); + expect(response._body.currentLocation.userProvided).toBe(correctLocationParams); + }); + + test('404 when results.length === 0', async () => { + const response = await agent + .get(`/api/timezone/${locationParamsThatResultsInNoMatch}`) // Make sure this is the intended test + .set('Authorization', volunteerToken) + .send(reqBody.body) + .expect(404); + + expect(response).toBeTruthy(); + }); + }); +}); diff --git a/src/routes/timeentryRouter.js b/src/routes/timeentryRouter.js index 0fd7db716..b5fd641ae 100644 --- a/src/routes/timeentryRouter.js +++ b/src/routes/timeentryRouter.js @@ -19,6 +19,14 @@ const routes = function (TimeEntry) { TimeEntryRouter.route('/TimeEntry/reports').post(controller.getTimeEntriesForReports); + TimeEntryRouter.route('/TimeEntry/reports/projects').post( + controller.getTimeEntriesForProjectReports, + ); + + TimeEntryRouter.route('/TimeEntry/reports/people').post( + controller.getTimeEntriesForPeopleReports, + ); + TimeEntryRouter.route('/TimeEntry/lostUsers').post(controller.getLostTimeEntriesForUserList); TimeEntryRouter.route('/TimeEntry/lostProjects').post( @@ -32,9 +40,11 @@ const routes = function (TimeEntry) { ); TimeEntryRouter.route('/TimeEntry/recalculateHoursAllUsers/tangible').post( - controller.recalculateHoursByCategoryAllUsers, + controller.startRecalculation, ); + TimeEntryRouter.route('/TimeEntry/checkStatus/:taskId').get(controller.checkRecalculationStatus); + TimeEntryRouter.route('/TimeEntry/recalculateHoursAllUsers/intangible').post( controller.recalculateIntangibleHrsAllUsers, ); diff --git a/src/routes/titleRouter.js b/src/routes/titleRouter.js index f12cb5ec7..1bced1e08 100644 --- a/src/routes/titleRouter.js +++ b/src/routes/titleRouter.js @@ -2,12 +2,14 @@ const express = require('express'); const router = function (title) { const controller = require('../controllers/titleController')(title); - const titleRouter = express.Router(); titleRouter.route('/title') .get(controller.getAllTitles) - .post(controller.postTitle); + .post(controller.postTitle) + // .put(controller.putTitle); + + titleRouter.route('/title/update').post(controller.updateTitle); titleRouter.route('/title/:titleId') .get(controller.getTitleById) diff --git a/src/routes/userProfileRouter.js b/src/routes/userProfileRouter.js index bf6f79237..2d68d2da1 100644 --- a/src/routes/userProfileRouter.js +++ b/src/routes/userProfileRouter.js @@ -23,6 +23,9 @@ const routes = function (userProfile, project) { controller.postUserProfile, ); + userProfileRouter.route('/userProfile/update').patch(controller.updateUserInformation); + // Endpoint to retrieve basic user profile information + userProfileRouter.route('/userProfile/basicInfo').get(controller.getUserProfileBasicInfo); userProfileRouter .route('/userProfile/:userId') .get(controller.getUserById) @@ -102,10 +105,21 @@ const routes = function (userProfile, project) { .route('/userProfile/authorizeUser/weeeklySummaries') .post(controller.authorizeUser); + userProfileRouter.route('/userProfile/:userId/addInfringement').post(controller.addInfringements); + + userProfileRouter + .route('/userProfile/:userId/infringements/:blueSquareId') + .put(controller.editInfringements) + .delete(controller.deleteInfringements); + userProfileRouter.route('/userProfile/projects/:name').get(controller.getProjectsByPerson); userProfileRouter.route('/userProfile/teamCode/list').get(controller.getAllTeamCode); + userProfileRouter + .route('/userProfile/autocomplete/:searchText') + .get(controller.getUserByAutocomplete); + return userProfileRouter; }; diff --git a/src/startup/db.js b/src/startup/db.js index c3c61807c..719c17f94 100644 --- a/src/startup/db.js +++ b/src/startup/db.js @@ -33,7 +33,7 @@ const afterConnect = async () => { module.exports = function () { const uri = `mongodb://${process.env.user}:${encodeURIComponent(process.env.password)}@${process.env.cluster}/${process.env.dbName}?ssl=true&replicaSet=${process.env.replicaSetName}&authSource=admin`; - + mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true, diff --git a/src/startup/middleware.js b/src/startup/middleware.js index eef3bd71b..400f5af6c 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -10,9 +10,8 @@ module.exports = function (app) { } if ( - (req.originalUrl === '/api/login' - || req.originalUrl === '/api/forgotpassword') - && req.method === 'POST' + (req.originalUrl === '/api/login' || req.originalUrl === '/api/forgotpassword') && + req.method === 'POST' ) { next(); return; @@ -21,12 +20,26 @@ module.exports = function (app) { next(); return; } - if (((req.originalUrl === '/api/ProfileInitialSetup' || req.originalUrl === '/api/validateToken' || req.originalUrl === '/api/getTimeZoneAPIKeyByToken') && req.method === 'POST') || (req.originalUrl === '/api/getTotalCountryCount' && req.method === 'GET') || (req.originalUrl.includes('/api/timezone') && req.method === 'POST') + if ( + ((req.originalUrl === '/api/ProfileInitialSetup' || + req.originalUrl === '/api/validateToken' || + req.originalUrl === '/api/getTimeZoneAPIKeyByToken') && + req.method === 'POST') || + (req.originalUrl === '/api/getTotalCountryCount' && req.method === 'GET') || + (req.originalUrl.includes('/api/timezone') && req.method === 'POST') ) { next(); return; } - if (req.originalUrl === '/api/add-non-hgn-email-subscription' || req.originalUrl === '/api/confirm-non-hgn-email-subscription' || req.originalUrl === '/api/remove-non-hgn-email-subscription' && req.method === 'POST') { + if ( + req.originalUrl === '/api/add-non-hgn-email-subscription' || + req.originalUrl === '/api/confirm-non-hgn-email-subscription' || + (req.originalUrl === '/api/remove-non-hgn-email-subscription' && req.method === 'POST') + ) { + next(); + return; + } + if (req.originalUrl.startsWith('/api/jobs') && req.method === 'GET') { next(); return; } @@ -44,13 +57,12 @@ module.exports = function (app) { res.status(401).send('Invalid token'); return; } - if ( - !payload - || !payload.expiryTimestamp - || !payload.userid - || !payload.role - || moment().isAfter(payload.expiryTimestamp) + !payload || + !payload.expiryTimestamp || + !payload.userid || + !payload.role || + moment().isAfter(payload.expiryTimestamp) ) { res.status(401).send('Unauthorized request'); return; diff --git a/src/startup/routes.js b/src/startup/routes.js index 82a4155a8..8274188bc 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -4,7 +4,6 @@ const project = require('../models/project'); const information = require('../models/information'); const team = require('../models/team'); // const actionItem = require('../models/actionItem'); -const notification = require('../models/notification'); const wbs = require('../models/wbs'); const task = require('../models/task'); const popup = require('../models/popupEditor'); @@ -16,6 +15,8 @@ const inventoryItemType = require('../models/inventoryItemType'); const role = require('../models/role'); const rolePreset = require('../models/rolePreset'); const ownerMessage = require('../models/ownerMessage'); +const currentWarnings = require('../models/currentWarnings'); + // Title const title = require('../models/title'); const blueSquareEmailAssignment = require('../models/BlueSquareEmailAssignment'); @@ -49,12 +50,14 @@ const followUp = require('../models/followUp'); const userProfileRouter = require('../routes/userProfileRouter')(userProfile, project); const warningRouter = require('../routes/warningRouter')(userProfile); +const currentWarningsRouter = require('../routes/curentWarningsRouter')(currentWarnings); const badgeRouter = require('../routes/badgeRouter')(badge); const dashboardRouter = require('../routes/dashboardRouter')(weeklySummaryAIPrompt); const timeEntryRouter = require('../routes/timeentryRouter')(timeEntry); const projectRouter = require('../routes/projectRouter')(project); const informationRouter = require('../routes/informationRouter')(information); const teamRouter = require('../routes/teamRouter')(team); +const jobsRouter = require('../routes/jobsRouter'); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); const notificationRouter = require('../routes/notificationRouter')(); const loginRouter = require('../routes/loginRouter')(); @@ -158,10 +161,12 @@ module.exports = function (app) { app.use('/api', isEmailExistsRouter); app.use('/api', mapLocationRouter); app.use('/api', warningRouter); + app.use('/api', currentWarningsRouter); app.use('/api', titleRouter); app.use('/api', timeOffRequestRouter); app.use('/api', followUpRouter); app.use('/api', blueSquareEmailAssignmentRouter); + app.use('/api/jobs', jobsRouter); // bm dashboard app.use('/api/bm', bmLoginRouter); app.use('/api/bm', bmMaterialsRouter); diff --git a/src/test/createTestPermissions.js b/src/test/createTestPermissions.js index 58623ea3f..e0f9eddf1 100644 --- a/src/test/createTestPermissions.js +++ b/src/test/createTestPermissions.js @@ -51,7 +51,9 @@ const permissionsRoles = [ 'changeUserStatus', 'updatePassword', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', // WBS 'postWbs', 'deleteWbs', @@ -111,7 +113,9 @@ const permissionsRoles = [ 'getUserProfiles', 'getProjectMembers', 'putUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'getReporteesLimitRoles', 'updateTask', 'putTeam', @@ -139,7 +143,9 @@ const permissionsRoles = [ 'getUserProfiles', 'getProjectMembers', 'putUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'getReporteesLimitRoles', 'getAllInvInProjectWBS', 'postInvInProjectWBS', @@ -194,6 +200,8 @@ const permissionsRoles = [ 'editTimeEntryToggleTangible', 'deleteTimeEntry', 'postTimeEntry', + 'sendEmails', + 'sendEmailToAll', 'updatePassword', 'getUserProfiles', 'getProjectMembers', @@ -202,7 +210,9 @@ const permissionsRoles = [ 'putUserProfileImportantInfo', 'updateSummaryRequirements', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'postWbs', 'deleteWbs', 'getAllInvInProjectWBS', diff --git a/src/utilities/createInitialPermissions.js b/src/utilities/createInitialPermissions.js index 43dfec2a0..062679cdd 100644 --- a/src/utilities/createInitialPermissions.js +++ b/src/utilities/createInitialPermissions.js @@ -52,7 +52,9 @@ const permissionsRoles = [ 'changeUserRehireableStatus', 'updatePassword', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'manageAdminLinks', 'manageTimeOffRequests', 'changeUserRehireableStatus', @@ -149,7 +151,6 @@ const permissionsRoles = [ { roleName: 'Mentor', permissions: [ - 'updateTask', 'suggestTask', 'putReviewStatus', 'getReporteesLimitRoles', @@ -212,6 +213,8 @@ const permissionsRoles = [ 'editTimeEntryToggleTangible', 'deleteTimeEntry', 'postTimeEntry', + 'sendEmails', + 'sendEmailToAll', 'updatePassword', 'getUserProfiles', 'getProjectMembers', @@ -220,7 +223,9 @@ const permissionsRoles = [ 'putUserProfileImportantInfo', 'updateSummaryRequirements', 'deleteUserProfile', - 'infringementAuthorizer', + 'addInfringements', + 'editInfringements', + 'deleteInfringements', 'postWbs', 'deleteWbs', 'getAllInvInProjectWBS', @@ -251,7 +256,10 @@ const permissionsRoles = [ 'seeUsersInDashboard', 'changeUserRehireableStatus', - 'manageAdminLinks', + + 'removeUserFromTask', + + 'editHeaderMessage', ], }, ]; diff --git a/src/utilities/emailSender.js b/src/utilities/emailSender.js index 19834abf4..b0fb40112 100644 --- a/src/utilities/emailSender.js +++ b/src/utilities/emailSender.js @@ -2,112 +2,113 @@ const nodemailer = require('nodemailer'); const { google } = require('googleapis'); const logger = require('../startup/logger'); -const closure = () => { - const queue = []; - - const CLIENT_EMAIL = process.env.REACT_APP_EMAIL; - const CLIENT_ID = process.env.REACT_APP_EMAIL_CLIENT_ID; - const CLIENT_SECRET = process.env.REACT_APP_EMAIL_CLIENT_SECRET; - const REDIRECT_URI = process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI; - const REFRESH_TOKEN = process.env.REACT_APP_EMAIL_REFRESH_TOKEN; - // Create the email envelope (transport) - const transporter = nodemailer.createTransport({ - service: 'gmail', - auth: { - type: 'OAuth2', - user: CLIENT_EMAIL, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }, - }); +const config = { + email: process.env.REACT_APP_EMAIL, + clientId: process.env.REACT_APP_EMAIL_CLIENT_ID, + clientSecret: process.env.REACT_APP_EMAIL_CLIENT_SECRET, + redirectUri: process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI, + refreshToken: process.env.REACT_APP_EMAIL_REFRESH_TOKEN, + batchSize: 50, + concurrency: 3, + rateLimitDelay: 1000, +}; - const OAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +const OAuth2Client = new google.auth.OAuth2( + config.clientId, + config.clientSecret, + config.redirectUri, +); +OAuth2Client.setCredentials({ refresh_token: config.refreshToken }); - OAuth2Client.setCredentials({ refresh_token: REFRESH_TOKEN }); +// Create the email envelope (transport) +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + type: 'OAuth2', + user: config.email, + clientId: config.clientId, + clientSecret: config.clientSecret, + }, +}); - setInterval(async () => { - const nextItem = queue.shift(); +const sendEmail = async (mailOptions) => { + try { + const { token } = await OAuth2Client.getAccessToken(); + mailOptions.auth = { + user: config.email, + refreshToken: config.refreshToken, + accessToken: token, + }; + const result = await transporter.sendMail(mailOptions); + if (process.env.NODE_ENV === 'local') { + logger.logInfo(`Email sent: ${JSON.stringify(result)}`); + } + return result; + } catch (error) { + logger.logException(error, `Error sending email: ${mailOptions.to}`); + throw error; + } +}; - if (!nextItem) return; +const queue = []; +let isProcessing = false; - const { recipient, subject, message, cc, bcc, replyTo, acknowledgingReceipt, resolve, reject} = nextItem; +const processQueue = async () => { + if (isProcessing || queue.length === 0) return; - try { - // Generate the accessToken on the fly - const res = await OAuth2Client.getAccessToken(); - const ACCESSTOKEN = res.token; + isProcessing = true; + console.log('Processing email queue...'); - const mailOptions = { - from: CLIENT_EMAIL, - to: recipient, - cc, - bcc, - subject, - html: message, - replyTo, - auth: { - user: CLIENT_EMAIL, - refreshToken: REFRESH_TOKEN, - accessToken: ACCESSTOKEN, - }, - }; + const processBatch = async () => { + if (queue.length === 0) { + isProcessing = false; + return; + } - const result = await transporter.sendMail(mailOptions); - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(null, result); - } - // Prevent logging email in production - // Why? - // 1. Could create a security risk - // 2. Could create heavy loads on the server if emails are sent to many people - // 3. Contain limited useful info: - // result format : {"accepted":["emailAddr"],"rejected":[],"envelopeTime":209,"messageTime":566,"messageSize":317,"response":"250 2.0.0 OK 17***69 p11-2***322qvd.85 - gsmtp","envelope":{"from":"emailAddr", "to":"emailAddr"}} - if (process.env.NODE_ENV === 'local') { - logger.logInfo(`Email sent: ${JSON.stringify(result)}`); - } - resolve(result); + const batch = queue.shift(); + try { + console.log('Sending email...'); + await sendEmail(batch); } catch (error) { - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(error, null); - } - logger.logException( - error, - `Error sending email: from ${CLIENT_EMAIL} to ${recipient} subject ${subject}`, - `Extra Data: cc ${cc} bcc ${bcc}`, - ); - reject(error); + logger.logException(error, 'Failed to send email batch'); } - }, process.env.MAIL_QUEUE_INTERVAL || 1000); - const emailSender = function ( - recipient, - subject, - message, - cc = null, - bcc = null, - replyTo = null, - acknowledgingReceipt = null, - ) { - return new Promise((resolve, reject) => { - if (process.env.sendEmail) { - queue.push({ - recipient, - subject, - message, - cc, - bcc, - replyTo, - acknowledgingReceipt, - resolve, - reject, - }); - } else { - resolve('Email sending is disabled'); - } - }); + setTimeout(processBatch, config.rateLimitDelay); }; - return emailSender; + const concurrentProcesses = Array(config.concurrency).fill().map(processBatch); + + try { + await Promise.all(concurrentProcesses); + } finally { + isProcessing = false; + } +}; + +const emailSender = ( + recipients, + subject, + message, + attachments = null, + cc = null, + replyTo = null, +) => { + if (!process.env.sendEmail) return; + const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]; + for (let i = 0; i < recipients.length; i += config.batchSize) { + const batchRecipients = recipientsArray.slice(i, i + config.batchSize); + queue.push({ + from: config.email, + bcc: batchRecipients.join(','), + subject, + html: message, + attachments, + cc, + replyTo, + }); + } + console.log('Emails queued:', queue.length); + setImmediate(processQueue); }; -module.exports = closure(); +module.exports = emailSender; diff --git a/src/utilities/permissions.js b/src/utilities/permissions.js index 2299e8812..75aee3b59 100644 --- a/src/utilities/permissions.js +++ b/src/utilities/permissions.js @@ -64,10 +64,10 @@ const canRequestorUpdateUser = async (requestorId, targetUserId) => { // Find out a list of protected email account ids and allowed email id allowedEmailAccountIds = query .filter(({ email }) => ALLOWED_EMAIL_ACCOUNT.includes(email)) - .map(({ _id }) => _id); + .map(({ _id }) => _id.toString()); protectedEmailAccountIds = query .filter(({ email }) => PROTECTED_EMAIL_ACCOUNT.includes(email)) - .map(({ _id }) => _id); + .map(({ _id }) => _id.toString()); serverCache.setCache('protectedEmailAccountIds', protectedEmailAccountIds); serverCache.setCache('allowedEmailAccountIds', allowedEmailAccountIds);