diff --git a/.DS_Store b/.DS_Store index 1ad1b253c..8d8d400ad 100644 Binary files a/.DS_Store and b/.DS_Store differ 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 8eff8a588..9e07004c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -5788,7 +5837,7 @@ "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", @@ -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", @@ -10799,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", @@ -11511,6 +11560,14 @@ } } }, + "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", @@ -12330,6 +12387,23 @@ "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", @@ -12338,7 +12412,7 @@ "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", 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/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/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/emailController.js b/src/controllers/emailController.js index ce6b3990d..15243ef5b 100644 --- a/src/controllers/emailController.js +++ b/src/controllers/emailController.js @@ -1,13 +1,61 @@ // emailController.js -const nodemailer = require('nodemailer'); 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) { @@ -16,11 +64,36 @@ const sendEmail = async (req, res) => { } 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(result => { + console.log('Email sent successfully:', result); + res.status(200).send(`Email sent successfully to ${to}`); + }) + .catch(error => { + console.error('Error sending email:', error); + res.status(500).send('Error sending email'); + }); - console.log('to', to); - emailSender(to, subject, html); - return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); return res.status(500).send('Error sending email'); @@ -35,46 +108,44 @@ const sendEmailToAll = async (req, res) => { } 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 = ` - - - - + if (users.length === 0) { + return res.status(404).send('No users found'); + } + const recipientEmails = users.map((user) => user.email); + console.log('# sendEmailToAll to', recipientEmails.join(',')); + if (recipientEmails.length === 0) { + throw new Error('No recipients defined'); + } - - ${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 emailContent = ` - - - - + 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); + }), + ); - - ${html} -

Thank you for subscribing to our email updates!

-

If you would like to unsubscribe, please click here

- - `; - emailSender(email, subject, emailContent); - }); return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); @@ -112,13 +183,9 @@ const addNonHgnEmailSubscription = async (req, res) => { } const payload = { email }; - const token = jwt.sign( - payload, - jwtSecret, - { - expiresIn: 360, - }, - ); + const token = jwt.sign(payload, jwtSecret, { + expiresIn: 360, + }); const emailContent = ` 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/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/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 610d627a1..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); 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/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/teamController.js b/src/controllers/teamController.js index f1fd2241d..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); @@ -223,10 +281,12 @@ 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) => { @@ -310,6 +370,47 @@ const teamcontroller = function (Team) { }); }; + 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, @@ -320,6 +421,7 @@ const teamcontroller = function (Team) { assignTeamToUsers, getTeamMembership, updateTeamVisibility, + getAllTeamMembers }; }; diff --git a/src/controllers/timeEntryController.js b/src/controllers/timeEntryController.js index d144db6bc..44a50fcfb 100644 --- a/src/controllers/timeEntryController.js +++ b/src/controllers/timeEntryController.js @@ -420,7 +420,7 @@ const addEditHistory = async (

One Community

        -
+

ADMINISTRATIVE DETAILS:

Start Date: ${moment(userprofile.startDate).utc().format('M-D-YYYY')}

Role: ${userprofile.role}

@@ -594,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()); } @@ -867,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()); @@ -940,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()); @@ -1063,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 */ @@ -1209,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', @@ -1218,7 +1298,7 @@ const timeEntrycontroller = function (TimeEntry) { isActive: { $ne: false }, }, ' -createdDateTime', - ) + ).lean() .populate('teamId') .sort({ lastModifiedDateTime: -1 }) .then((results) => { @@ -1235,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); @@ -1488,6 +1569,8 @@ const timeEntrycontroller = function (TimeEntry) { backupIntangibleHrsAllUsers, recalculateIntangibleHrsAllUsers, getTimeEntriesForReports, + getTimeEntriesForProjectReports, + getTimeEntriesForPeopleReports, startRecalculation, checkRecalculationStatus, }; diff --git a/src/controllers/titleController.js b/src/controllers/titleController.js index 1240d3373..277842c43 100644 --- a/src/controllers/titleController.js +++ b/src/controllers/titleController.js @@ -1,4 +1,3 @@ -const Team = require('../models/team'); const Project = require('../models/project'); const cacheClosure = require('../utilities/nodeCache'); const userProfileController = require("./userProfileController"); @@ -6,198 +5,202 @@ const userProfile = require('../models/userProfile'); const project = require('../models/project'); const controller = userProfileController(userProfile, project); -const getAllTeamCodeHelper = controller.getAllTeamCodeHelper; +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)); + }; + + 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.' }); + } + + 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; + } + + // if media is empty + if (!title.mediaFolder.trim()) { + res.status(400).send({ message: 'Media folder cannot be empty.' }); + return; + } - Title.findById(titleId) - .then((results) => res.send(results)) - .catch((error) => res.send(error)); - }; + if (!title.teamCode) { + res.status(400).send({ message: 'Please provide a team code.' }); + return; + } - const postTitle = async function (req, res) { - const title = new Title(); - 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; + 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 }); + } - 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 (!req.body.titleName.trim()) { - res.status(400).send({ message: 'Title cannot be empty.' }); - return; - } - - // if media is empty - if (!req.body.mediaFolder.trim()) { - res.status(400).send({ message: 'Media folder cannot be empty.' }); - return; - } - const shortnames = req.body.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(); - } - req.body.shortName = shortname; - - // Validate team code by checking if it exists in the database - if (!req.body.teamCode) { - res.status(400).send({ message: 'Please provide a team code.' }); - return; - } - - 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(req.body.projectAssigned._id); - if (!projectExist) { - res.status(400).send({ message: 'Project is empty or not exist.' }); - return; - } - - // validate if team exist - 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.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)); - }; - - 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) => { - console.log(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 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.` }); } - 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, @@ -209,5 +212,4 @@ const titlecontroller = function (Title) { }; }; - 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 15f36f751..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; @@ -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(); } @@ -888,9 +921,9 @@ const userProfileController = function (UserProfile, Project) { options: { sort: { date: -1, // Sort by date descending if needed - }, }, }, + }, ]) .exec() .then((results) => { @@ -1035,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", }); @@ -1178,7 +1211,7 @@ const userProfileController = function (UserProfile, Project) { const setEndDate = dateObject; if (moment().isAfter(moment(setEndDate).add(1, 'days'))) { activeStatus = false; - }else if(moment().isBefore(moment(endDate).subtract(3, 'weeks'))){ + } else if (moment().isBefore(moment(endDate).subtract(3, 'weeks'))) { emailThreeWeeksSent = true; } } @@ -1460,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'); @@ -1524,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; @@ -1603,7 +1611,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + 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, }); @@ -1645,7 +1663,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + 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, }); @@ -1660,6 +1688,7 @@ const userProfileController = function (UserProfile, Project) { return; } const { userId, blueSquareId } = req.params; + // console.log(userId, blueSquareId); UserProfile.findById(userId, async (err, record) => { if (err || !record) { @@ -1676,7 +1705,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + 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, }); @@ -1694,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); @@ -1761,9 +1800,59 @@ const userProfileController = function (UserProfile, Project) { .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) + } + } + return { postUserProfile, getUserProfiles, @@ -1792,6 +1881,9 @@ const userProfileController = function (UserProfile, Project) { getProjectsByPerson, getAllTeamCode, getAllTeamCodeHelper, + getUserByAutocomplete, + getUserProfileBasicInfo, + updateUserInformation, }; }; diff --git a/src/cronjobs/userProfileJobs.js b/src/cronjobs/userProfileJobs.js index 68af6a12e..e773847d2 100644 --- a/src/cronjobs/userProfileJobs.js +++ b/src/cronjobs/userProfileJobs.js @@ -20,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, @@ -28,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/overviewReportHelper.js b/src/helpers/overviewReportHelper.js index 52d6a2ad0..cb1cd8edb 100644 --- a/src/helpers/overviewReportHelper.js +++ b/src/helpers/overviewReportHelper.js @@ -74,6 +74,8 @@ const overviewReportHelper = function () { _id: 1, firstName: 1, lastName: 1, + email: 1, + profilePic: 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 7691be296..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); @@ -2051,55 +2076,55 @@ const userHelper = function () {

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')}.

- +

With Gratitude,

- +

One Community

`; 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 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

`; emailSender(email, subject, emailBody, null, recipients, email); } 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/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/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/timeentryRouter.js b/src/routes/timeentryRouter.js index cc8d04c57..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( diff --git a/src/routes/titleRouter.js b/src/routes/titleRouter.js index ce00e2279..1bced1e08 100644 --- a/src/routes/titleRouter.js +++ b/src/routes/titleRouter.js @@ -7,7 +7,7 @@ const router = function (title) { titleRouter.route('/title') .get(controller.getAllTitles) .post(controller.postTitle) - // .put(controller.putTitle); + // .put(controller.putTitle); titleRouter.route('/title/update').post(controller.updateTitle); diff --git a/src/routes/userProfileRouter.js b/src/routes/userProfileRouter.js index 513aa687e..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) @@ -113,6 +116,10 @@ const routes = function (userProfile, project) { userProfileRouter.route('/userProfile/teamCode/list').get(controller.getAllTeamCode); + userProfileRouter + .route('/userProfile/autocomplete/:searchText') + .get(controller.getUserByAutocomplete); + return userProfileRouter; }; diff --git a/src/startup/middleware.js b/src/startup/middleware.js index 6304f1840..400f5af6c 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -39,6 +39,10 @@ module.exports = function (app) { next(); return; } + if (req.originalUrl.startsWith('/api/jobs') && req.method === 'GET') { + next(); + return; + } if (!req.header('Authorization')) { res.status(401).send({ 'error:': 'Unauthorized request' }); return; diff --git a/src/startup/routes.js b/src/startup/routes.js index beb92bc37..8274188bc 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -4,8 +4,6 @@ const project = require('../models/project'); const information = require('../models/information'); const team = require('../models/team'); // const actionItem = require('../models/actionItem'); -// eslint-disable-next-line -const notification = require('../models/notification'); const wbs = require('../models/wbs'); const task = require('../models/task'); const popup = require('../models/popupEditor'); @@ -59,6 +57,7 @@ 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')(); @@ -167,6 +166,7 @@ module.exports = function (app) { 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/utilities/createInitialPermissions.js b/src/utilities/createInitialPermissions.js index 93f4c067c..062679cdd 100644 --- a/src/utilities/createInitialPermissions.js +++ b/src/utilities/createInitialPermissions.js @@ -153,7 +153,6 @@ const permissionsRoles = [ permissions: [ 'suggestTask', 'putReviewStatus', - 'putUserProfile', 'getReporteesLimitRoles', 'getAllInvInProjectWBS', 'postInvInProjectWBS', @@ -257,7 +256,8 @@ const permissionsRoles = [ 'seeUsersInDashboard', 'changeUserRehireableStatus', - 'manageAdminLinks', + + 'removeUserFromTask', 'editHeaderMessage', ], diff --git a/src/utilities/emailSender.js b/src/utilities/emailSender.js index eb8eca3de..b0fb40112 100644 --- a/src/utilities/emailSender.js +++ b/src/utilities/emailSender.js @@ -2,104 +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 } = 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)}`); - } + 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}`, - ); + 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, - ) { - if (process.env.sendEmail) { - queue.push({ - recipient, - subject, - message, - cc, - bcc, - replyTo, - acknowledgingReceipt, - }); - } + 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);